Merge branch 'feat/desktop-offline' into feat/do-reminders

# Conflicts:
#	lib/desktop/types.ts
#	lib/offline/offline-mode.ts
#	lib/store/areas.ts
#	locales/ar/common.json
#	locales/ca/common.json
#	locales/cs/common.json
#	locales/da/common.json
#	locales/de/common.json
#	locales/en/common.json
#	locales/es/common.json
#	locales/et/common.json
#	locales/fa/common.json
#	locales/fr/common.json
#	locales/he/common.json
#	locales/hu/common.json
#	locales/it/common.json
#	locales/ja/common.json
#	locales/ko/common.json
#	locales/lv/common.json
#	locales/mn/common.json
#	locales/nb/common.json
#	locales/nl/common.json
#	locales/pl/common.json
#	locales/pt/common.json
#	locales/ro/common.json
#	locales/ru/common.json
#	locales/sk/common.json
#	locales/th/common.json
#	locales/tr/common.json
#	locales/uk/common.json
#	locales/vi/common.json
#	locales/zh-TW/common.json
#	locales/zh/common.json
This commit is contained in:
Linus Rath
2026-11-03 14:46:00 +01:00
179 changed files with 15540 additions and 268 deletions
+46 -4
View File
@@ -1,8 +1,10 @@
"use client";
import { Suspense, useState, useEffect, useRef, useCallback, useId } from "react";
import { Suspense, useState, useEffect, useRef, useCallback, useId, useMemo } from "react";
import { IS_DESKTOP_BUILD } from "@/lib/desktop/flag";
import { forgetDesktopServer, getDesktopBridge } from "@/lib/desktop/bridge";
import { customServerAllowedByDesktopPolicy, jmapServerAllowedByDesktopPolicy, useDesktopPolicy } from "@/lib/desktop/policy";
import { accountRefusedByDesktopPolicy } from "@/lib/desktop/refused-accounts";
import { browserSignInAvailable, runDesktopBrowserSignIn } from "@/lib/desktop/browser-sign-in";
import { useRouter } from "@/i18n/navigation";
import { useParams, useSearchParams } from "next/navigation";
@@ -189,6 +191,8 @@ function LoginPageContent() {
const router = useRouter();
const t = useTranslations("login");
const tDesktop = useTranslations("desktop_sign_in");
const tDesktopPolicy = useTranslations("desktop_policy");
const desktopPolicy = useDesktopPolicy();
// Only the desktop build sets these; a browser never shows them.
const [desktopNotice, setDesktopNotice] = useState<string | null>(null);
const [desktopWaiting, setDesktopWaiting] = useState(false);
@@ -198,6 +202,15 @@ function LoginPageContent() {
const params = useParams();
const searchParams = useSearchParams();
const isAddAccountMode = searchParams.get("mode") === "add-account";
const [desktopPolicyNotice, setDesktopPolicyNotice] = useState<string | null>(null);
// Whichever way someone got here: every account they have is on a server the device's
// OS policy refuses for now. They wait, and come back when it's allowed again.
useEffect(() => {
const stored = useAccountStore.getState().accounts;
if (stored.length > 0 && stored.every((account) => accountRefusedByDesktopPolicy(account, desktopPolicy))) {
setDesktopPolicyNotice(tDesktopPolicy("server_refused"));
}
}, [desktopPolicy, tDesktopPolicy]);
// When the mobile app launches the webmail in a browser tab it tacks on
// these params. We grab them once at mount and stash them in a ref so any
@@ -228,7 +241,14 @@ function LoginPageContent() {
usePrefetchMailView(!isMobileHandoff);
const { login, loginWithToken, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore();
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
const { appName, jmapServerUrl: configuredServerUrl, oauthEnabled, oauthOnly, oauthClientId: globalOauthClientId, oauthIssuerUrl: globalOauthIssuerUrl, oauthScopes, oauthResource, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, loginUsernamePlaceholder, loginLogoMaxHeight, loginLogoMaxWidth, loginShowHeading, loginShowSubtitle, loginShowTotp, loginShowTokenLogin, loginShowRememberMe, loginShowVersion, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint, jmapServers, jmapServerAutoPickByDomain } = useConfig();
const { appName, jmapServerUrl: configuredServerUrl, oauthEnabled, oauthOnly, oauthClientId: globalOauthClientId, oauthIssuerUrl: globalOauthIssuerUrl, oauthScopes, oauthResource, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, loginUsernamePlaceholder, loginLogoMaxHeight, loginLogoMaxWidth, loginShowHeading, loginShowSubtitle, loginShowTotp, loginShowTokenLogin, loginShowRememberMe, loginShowVersion, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint: configAllowsCustomEndpoint, jmapServers: configuredServers, jmapServerAutoPickByDomain } = useConfig();
// The device's OS policy narrows what the server offers. A forced Bulwark server hides
// the field for a server of one's own, unless that field is the only way to sign in there.
const allowCustomJmapEndpoint = configAllowsCustomEndpoint && (customServerAllowedByDesktopPolicy(desktopPolicy) || !configuredServerUrl);
const jmapServers = useMemo(
() => (desktopPolicy.AllowedJmapServers.value ? configuredServers.filter((s) => jmapServerAllowedByDesktopPolicy(s.url, desktopPolicy)) : configuredServers),
[configuredServers, desktopPolicy],
);
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
const colorModeLocked = usePolicyStore((s) => s.getColorModePolicy().locked);
@@ -619,6 +639,11 @@ function LoginPageContent() {
const signedOutOnPurpose = useRef<boolean | null>(null);
const startServerSideSso = useCallback(async () => {
// The device's OS policy, as for a password: nothing starts towards a refused server.
if (selectedServer?.url && !jmapServerAllowedByDesktopPolicy(selectedServer.url, desktopPolicy)) {
setDesktopPolicyNotice(tDesktopPolicy("server_refused"));
return;
}
setOauthLoading(true);
try {
const prefix = getPathPrefix(params.locale as string);
@@ -675,7 +700,7 @@ function LoginPageContent() {
} catch {
setOauthLoading(false);
}
}, [params.locale, selectedServer?.id, persistProviderSession, isMobileHandoff, mobileRedirectUri, mobileState, mobileChallenge]);
}, [params.locale, selectedServer?.id, selectedServer?.url, desktopPolicy, tDesktopPolicy, persistProviderSession, isMobileHandoff, mobileRedirectUri, mobileState, mobileChallenge]);
useEffect(() => {
// After signing out, show the login page rather than signing straight
@@ -905,6 +930,11 @@ function LoginPageContent() {
* whichever server is selected so that "Sign in with Google" is one tap.
*/
const handleOAuthLogin = async (target?: PublicJmapServerEntry) => {
const oauthTarget = (target ?? selectedServer)?.url || (allowCustomJmapEndpoint ? jmapEndpoint : configuredServerUrl);
if (oauthTarget && !jmapServerAllowedByDesktopPolicy(oauthTarget, desktopPolicy)) {
setDesktopPolicyNotice(tDesktopPolicy("server_refused"));
return;
}
if (LITE_OAUTH_AVAILABLE && !target) {
await startLiteOAuth(probeTarget);
return;
@@ -1031,13 +1061,20 @@ function LoginPageContent() {
// when the admin hasn't configured a server list.
const effectiveServerUrl = selectedServer?.url
|| (allowCustomJmapEndpoint ? jmapEndpoint : serverUrl);
// The device's OS policy, also on an instance: nothing is sent to a server it refuses.
setDesktopPolicyNotice(null);
if (!jmapServerAllowedByDesktopPolicy(effectiveServerUrl, desktopPolicy)) {
setDesktopPolicyNotice(tDesktopPolicy("server_refused"));
return;
}
// A LAN or plain http server gets a native prompt first, and the app can
// reach it from then on. Without a yes, nothing is sent there.
if (IS_DESKTOP_BUILD) {
setDesktopNotice(null);
const prepared = await getDesktopBridge()?.servers?.prepare(effectiveServerUrl);
if (prepared && !prepared.ok) {
setDesktopNotice(prepared.reason === "invalid" ? tDesktop("server_invalid") : tDesktop("server_declined"));
if (prepared.reason === "policy") setDesktopPolicyNotice(tDesktopPolicy("server_refused"));
else setDesktopNotice(prepared.reason === "invalid" ? tDesktop("server_invalid") : tDesktop("server_declined"));
return;
}
}
@@ -1880,6 +1917,11 @@ function LoginPageContent() {
{desktopWaiting ? tDesktop("browser_waiting") : tDesktop("browser_button")}
</Button>
)}
{desktopPolicyNotice && (
<p role="alert" data-testid="desktop-policy-notice" className="mt-2 text-sm text-warning leading-relaxed">
{desktopPolicyNotice}
</p>
)}
{IS_DESKTOP_BUILD && desktopNotice && (
<p role="alert" data-testid="desktop-notice" className="mt-2 text-sm text-warning leading-relaxed">
{desktopNotice}
@@ -7,6 +7,7 @@ import { getPlugin, getTheme, savePlugin, saveTheme } from '@/lib/admin/plugin-r
import { invalidateFrameOriginsCache } from '@/lib/admin/csp-frame-origins';
import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response';
import { EXTENSION_ID_RE } from '@/lib/extension-package';
import { devicePolicyPluginRefusal } from '@/lib/desktop/server-policy';
/**
* POST /api/admin/extensions/[kind]/[id]/rollback
@@ -32,6 +33,10 @@ export async function POST(
return NextResponse.json({ error: 'Unknown extension kind' }, { status: 400 });
}
if (!EXTENSION_ID_RE.test(id)) return NextResponse.json({ error: 'Invalid extension id' }, { status: 400 });
if (kind === 'plugin') {
const refused = devicePolicyPluginRefusal(id);
if (refused) return refused;
}
const body = await request.json().catch(() => ({}));
const version = typeof body?.version === 'string' ? body.version.trim() : '';
+6
View File
@@ -44,6 +44,7 @@ import {
} from '@/lib/admin/marketplace-directory';
import { MAX_PLUGIN_SIZE, MAX_THEME_SIZE } from '@/lib/plugin-types';
import { createHash } from 'node:crypto';
import { devicePolicyPluginRefusal } from '@/lib/desktop/server-policy';
/** Query parameters the UI may pass on to the directory's list endpoint. */
const FORWARDED = ['q', 'type', 'pluginType', 'tag', 'author', 'sort', 'order', 'page', 'perPage', 'featured'] as const;
@@ -161,6 +162,11 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Invalid version: must look like 1.2.3' }, { status: 400 });
}
if (type === 'plugin') {
const refused = devicePolicyPluginRefusal(slug);
if (refused) return refused;
}
const directoryUrl = await getDirectoryUrl();
const versionPath = `/extensions/${encodeURIComponent(slug)}/versions/${encodeURIComponent(version)}`;
+6
View File
@@ -5,6 +5,7 @@ import { logger } from '@/lib/logger';
import { listApprovals, decideApproval, revokeApproval } from '@/lib/admin/plugin-approvals';
import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response';
import { storeUnavailableResponse } from '@/lib/admin/store-unavailable-response';
import { devicePolicyPluginRefusal } from '@/lib/desktop/server-policy';
/**
* Admin-protected CRUD for the per-(pluginId, bundleHash) approval table.
@@ -55,6 +56,11 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'decision must be "approved" or "denied"' }, { status: 400 });
}
if (b.decision === 'approved') {
const refused = devicePolicyPluginRefusal(b.pluginId);
if (refused) return refused;
}
const entry = await decideApproval(b.pluginId, b.bundleHash, b.decision, adminUser);
await auditLog('plugin.approval', { pluginId: entry.pluginId, bundleHash: entry.bundleHash, decision: entry.status }, ip);
return NextResponse.json({ entry });
@@ -4,6 +4,7 @@ import { getDevPlugin, readDevBundle } from '@/lib/admin/plugin-dev';
import { signBytes } from '@/lib/admin/plugin-signing';
import { canSeeInstanceDetails } from '@/lib/auth/instance-details';
import { storeUnavailableResponse } from '@/lib/admin/store-unavailable-response';
import { devicePolicyPluginRefusal } from '@/lib/desktop/server-policy';
async function safeSign(code: string): Promise<string | null> {
try { return await signBytes(code); } catch { return null; }
@@ -32,6 +33,9 @@ export async function GET(
return NextResponse.json({ error: 'Invalid plugin ID' }, { status: 400 });
}
const refused = devicePolicyPluginRefusal(id);
if (refused) return refused;
// Dev plugins are read (and optionally bundled) straight from disk and
// served with no caching so every refresh picks up the latest source.
const devEntry = await getDevPlugin(id);
+10
View File
@@ -17,6 +17,7 @@ import { keptVersions, listHistory } from '@/lib/admin/extension-history';
import { ExtensionPackageError, readPluginPackage, suspiciousCode } from '@/lib/extension-package';
import { MAX_PLUGIN_SIZE } from '@/lib/plugin-types';
import { configReadOnlyResponse } from '@/lib/admin/config-read-only-response';
import { devicePolicyPluginRefusal } from '@/lib/desktop/server-policy';
/**
* GET /api/admin/plugins - List all admin-managed plugins
@@ -87,6 +88,9 @@ export async function POST(request: NextRequest) {
}
const { manifest, code } = pkg;
const refused = devicePolicyPluginRefusal(manifest.id);
if (refused) return refused;
// Admin uploads reach every user, so the patterns a user upload only
// warns about are refused here.
const suspicious = suspiciousCode(code);
@@ -160,6 +164,12 @@ export async function PATCH(request: NextRequest) {
return NextResponse.json({ error: 'enabled or forceEnabled must be a boolean' }, { status: 400 });
}
// Turning one off stays possible: the policy only stops what would run.
if (enabled === true || forceEnabled === true) {
const refused = devicePolicyPluginRefusal(id);
if (refused) return refused;
}
const updates: { enabled?: boolean; forceEnabled?: boolean } = {};
if (typeof enabled === 'boolean') updates.enabled = enabled;
if (typeof forceEnabled === 'boolean') updates.forceEnabled = forceEnabled;
+9
View File
@@ -25,6 +25,7 @@ import { loginClientAddress } from '@/lib/auth/client-address';
import { LOGIN_BUDGET_RETRY_AFTER_SECONDS, reserveLoginAttempt } from '@/lib/auth/verify-budget';
import { isStoreUnavailable, storeUnavailableResponse } from '@/lib/auth/store-unavailable';
import { isPastMaxAge, limitsEnforcedSince, maxSessionHoursFor, SESSION_TOO_OLD } from '@/lib/auth/session-age';
import { devicePolicyServerRefusal } from '@/lib/desktop/server-policy';
function sessionCookieOptions() {
return {
@@ -142,6 +143,9 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
const refusedByDevice = devicePolicyServerRefusal(serverUrl);
if (refusedByDevice) return refusedByDevice;
// Pin the upstream URL to a configured JMAP server so an unauthenticated
// caller cannot point this route at internal hosts. We accept the global
// `jmapServerUrl` and any entry from `jmapServers`. When neither matches,
@@ -315,6 +319,11 @@ export async function PUT(request: NextRequest) {
return NextResponse.json({ error: 'Invalid session' }, { status: 401 });
}
// The device's OS policy refuses this server for now: nothing restores, and the
// cookie stays, so the account comes back with no sign-in if the policy does.
const refusedByDevice = devicePolicyServerRefusal(credentials.serverUrl);
if (refusedByDevice) return refusedByDevice;
const age = await checkSessionAge(cookieStore, slot, credentials);
if ('maxHours' in age) return sessionTooOld(age.maxHours);
+4
View File
@@ -16,6 +16,7 @@ import { loginClientAddress } from '@/lib/auth/client-address';
import { LOGIN_BUDGET_RETRY_AFTER_SECONDS, reserveLoginAttempt } from '@/lib/auth/verify-budget';
import { isStoreUnavailable, storeUnavailableResponse } from '@/lib/auth/store-unavailable';
import { recordStoredCredentialStatus } from '@/lib/stalwart/stored-credential-budget';
import { devicePolicyServerRefusal } from '@/lib/desktop/server-policy';
function getSlot(request: NextRequest, bodySlot: unknown): number {
if (typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot < MAX_ACCOUNT_SLOTS) {
@@ -43,6 +44,9 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
}
const refusedByDevice = devicePolicyServerRefusal(serverUrl);
if (refusedByDevice) return refusedByDevice;
// Pin the upstream URL to a configured JMAP server (single `jmapServerUrl`
// or any entry in `jmapServers`). Falls back to the request URL only when
// `allowCustomJmapEndpoint` is enabled, and even then it must be public.
+5
View File
@@ -42,6 +42,7 @@ import {
} from '@/lib/auth/session-age';
import { clearStalwartAuthContextInStore, readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context';
import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag';
import { devicePolicyServerRefusal } from '@/lib/desktop/server-policy';
import {
clearCustomUpstream,
readCustomUpstream,
@@ -277,6 +278,10 @@ export async function PUT(request: NextRequest) {
// waits it out. A 500 here would count toward signing the user out.
let tokenResponse: Response;
const upstream = IS_DESKTOP_BUILD ? await readCustomUpstream(cookieStore, slot) : null;
// The device's OS policy refuses this server for now. The cookies stay, so the
// account comes back with no sign-in if the policy does.
const refusedByDevice = upstream ? devicePolicyServerRefusal(upstream.serverUrl) : null;
if (refusedByDevice) return refusedByDevice;
try {
const tokenEndpoint = await getTokenEndpoint(serverId, upstream ? { upstream } : { fallbackClientId: DEFAULT_CLIENT_ID });
@@ -18,6 +18,7 @@ import { isStoreUnavailable, storeUnavailableResponse } from '@/lib/auth/store-u
import { writeSignInStamp } from '@/lib/auth/session-age';
import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag';
import { isDesktopTrustedServer } from '@/lib/desktop/trusted-servers';
import { devicePolicyServerRefusal } from '@/lib/desktop/server-policy';
import { stalwartDefaultClientId } from '@/lib/auth/stalwart-password-login';
import { writeCustomUpstream } from '@/lib/oauth/custom-upstream';
@@ -162,6 +163,9 @@ export async function POST(request: NextRequest) {
const serverList = parseJmapServers(configManager.get<unknown>('jmapServers', []));
let upstreamUrl: string;
const refusedByDevice = devicePolicyServerRefusal(serverUrl);
if (refusedByDevice) return refusedByDevice;
let upstreamTrusted = true;
let resolvedServerId: string | null = null;
const requestedEntry = findServerById(serverList, requestedServerId);
+4
View File
@@ -8,6 +8,7 @@ import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
import { loginClientAddress } from '@/lib/auth/client-address';
import { reserveVerifyProbe, type BudgetRefund } from '@/lib/auth/verify-budget';
import { isStoreUnavailable } from '@/lib/auth/store-unavailable';
import { devicePolicyServerRefusal } from '@/lib/desktop/server-policy';
/**
* Server-side credential pre-check for the login form (#969): a password
@@ -58,6 +59,9 @@ export async function POST(request: NextRequest) {
return respond('inconclusive');
}
const refusedByDevice = devicePolicyServerRefusal(serverUrl);
if (refusedByDevice) return refusedByDevice;
// Same upstream pinning as /api/auth/session: an unauthenticated caller
// must not be able to point this route at arbitrary internal hosts.
const configuredServerUrl =
+6
View File
@@ -7,6 +7,7 @@ import { logger } from '@/lib/logger';
import { storeUnavailableResponse } from '@/lib/admin/store-unavailable-response';
import { isNoThirdPartyTrafficOn } from '@/lib/admin/third-party-mode';
import { pluginAllowedInMode } from '@/lib/outgoing-hosts';
import { pluginRefusedByDevicePolicy } from '@/lib/desktop/server-policy';
/**
* GET /api/plugins - Public endpoint for clients to discover server-managed plugins & themes
@@ -66,6 +67,11 @@ export async function GET(request: NextRequest) {
updatedAt: p.updatedAt,
// Marks plugins loaded from PLUGIN_DEV_DIR. Surface in UI as a badge.
dev: p.dev,
// The device's OS policy refuses this plugin. It stays on the list: a plugin
// missing from the list counts as removed by the admin, and the client then
// deletes its bundle, its data and its connected accounts. Tightening a policy
// must not destroy anything, and loosening it again brings all of it back.
...(pluginRefusedByDevicePolicy(p.id) ? { refused: true } : {}),
// Surface so clients can enforce api.http.fetch origin allowlists.
httpOrigins: p.httpOrigins,
// Surface so clients can enforce api.http.post path allowlists.
@@ -70,6 +70,31 @@ describe('Offline mode section', () => {
it('says what a device keeps and links the docs', async () => {
await renderSection();
expect(screen.getByRole('link', { name: 'How offline mode works' }).getAttribute('href')).toBe('https://bulwarkmail.org/docs/guides/organisation/offline-mode');
expect(document.body.textContent).toContain('Bulwark Lite has no offline mode');
expect(document.body.textContent).toContain('Bulwark Lite reads the same policy but has no offline mode');
expect(document.body.textContent).not.toContain('250 MB');
});
it('saves the caps as numbers and takes a cleared field as no cap', async () => {
await renderSection();
const days = screen.getByTestId('org-devices-offlineMaxDays') as HTMLInputElement;
fireEvent.change(days, { target: { value: '45' } });
fireEvent.change(screen.getByTestId('org-devices-offlineMaxAttachmentMb'), { target: { value: '10' } });
fireEvent.change(screen.getByTestId('org-devices-offlineMaxTotalMb'), { target: { value: '200' } });
fireEvent.click(screen.getByTestId('org-devices-noOfflineAttachments'));
await act(async () => { await latest!.save(); });
expect((puts[0].organisation as { devices: unknown }).devices).toMatchObject({
offlineMaxDays: 45, offlineMaxAttachmentMb: 10, offlineMaxTotalMb: 200, noOfflineAttachments: true,
});
fireEvent.change(days, { target: { value: '' } });
expect(latest!.policy?.organisation?.devices.offlineMaxDays).toBe(0);
expect(days.value).toBe('');
});
it('writes a cap into the scope only', async () => {
await renderSection();
act(() => latest!.setScopeId('field'));
fireEvent.change(screen.getByTestId('org-devices-offlineMaxDays'), { target: { value: '30' } });
expect(latest!.policy?.scopes?.[0].policy.organisation).toEqual({ devices: { offlineMaxDays: 30 } });
expect(latest!.policy?.organisation?.devices.offlineMaxDays).toBe(0);
});
});
@@ -6,34 +6,113 @@ import type { OrgPolicyDraft } from '../use-org-policy-draft';
const DOCS_URL = 'https://bulwarkmail.org/docs/guides/organisation/offline-mode';
interface CapField {
key: 'offlineMaxDays' | 'offlineMaxAttachmentMb' | 'offlineMaxTotalMb';
label: string;
description: string;
unit: string;
max: number;
}
const CAP_FIELDS: CapField[] = [
{
key: 'offlineMaxDays',
label: 'Keep mail for at most',
description: 'Mail older than this leaves the device at its next policy fetch, and calendar events that ended before it too. People can still choose less.',
unit: 'days',
max: 3650,
},
{
key: 'offlineMaxAttachmentMb',
label: 'Largest attachment or file kept',
description: 'Anything bigger leaves the device and opens from the server when there is a connection. This also holds for files someone chose to keep offline.',
unit: 'MB',
max: 1024 * 1024,
},
{
key: 'offlineMaxTotalMb',
label: 'All attachments and files, per account',
description: 'The oldest go first when a device is over this. Message text isn’t counted: limit that with the days above.',
unit: 'MB',
max: 1024 * 1024,
},
];
const describeCap = (value: number, unit: string) => (value > 0 ? `${value} ${unit}` : 'no limit');
/**
* Organisation > Devices > Offline mode (#523): whether people may keep
* mail on their devices (policy.json `organisation.devices.noOffline`).
* Users turn offline mode on per device; this switch takes it away, and a
* device that already keeps mail deletes it at its next policy fetch.
* Organisation > Devices > Offline mode (#523): whether people may keep mail
* on their devices, and how much (policy.json `organisation.devices`). These
* win over what a user picks and over the desktop app's default. Nothing on
* the server can check them, since the mail is on the device: the app holds
* itself to them, which the text says.
*/
export function OfflineSection({ draft }: { draft: OrgPolicyDraft }) {
const { value, own, everyone } = draft.orgValue('devices', 'noOffline');
const forbid = draft.orgValue('devices', 'noOffline');
const noAttachments = draft.orgValue('devices', 'noOfflineAttachments');
return (
<SettingsSection
id="offline"
title="Offline mode"
description="People can turn on “Keep mail on this device” in Settings > Backup & reset to read and write mail without a connection. It is off on every device until someone turns it on there."
description="People can keep mail on a device to read and write it without a connection. In browsers it is off until someone turns on “Keep mail on this device” in Settings > Backup & reset. In the Bulwark desktop app it is on unless you forbid it here."
>
<SettingItem
label="Don't let people keep mail on their devices"
description="Hides the switch. A device that already keeps mail deletes it, unsent messages in its outbox included, the next time it fetches the policy (when the tab is opened or focused, and every 15 minutes)."
>
<div className="flex flex-col items-start gap-1 sm:items-end">
<ToggleSwitch checked={value} onChange={(v) => draft.setOrgValue('devices', 'noOffline', v)} testId="org-devices-noOffline" />
<InheritNote draft={draft} own={own} everyone={everyone ? 'on' : 'off'} onReset={() => draft.clearOrgValue('devices', 'noOffline')} />
<ToggleSwitch checked={forbid.value} onChange={(v) => draft.setOrgValue('devices', 'noOffline', v)} testId="org-devices-noOffline" />
<InheritNote draft={draft} own={forbid.own} everyone={forbid.everyone ? 'on' : 'off'} onReset={() => draft.clearOrgValue('devices', 'noOffline')} />
</div>
</SettingItem>
{CAP_FIELDS.map((field) => {
const { value, own, everyone } = draft.orgValue('devices', field.key);
return (
<SettingItem key={field.key} label={field.label} description={field.description}>
<div className="flex flex-col items-start gap-1 sm:items-end">
<div className="flex items-center gap-2">
<input
type="number"
inputMode="numeric"
min={0}
max={field.max}
step={1}
data-testid={`org-devices-${field.key}`}
aria-label={`${field.label} (${field.unit})`}
placeholder="No limit"
value={value > 0 ? value : ''}
onChange={(e) => {
const raw = e.target.value.trim();
const parsed = raw === '' ? 0 : Math.round(Number(raw));
if (!Number.isFinite(parsed)) return;
draft.setOrgValue('devices', field.key, Math.min(field.max, Math.max(0, parsed)));
}}
className="h-9 w-28 rounded-md border border-input bg-background px-3 text-sm text-foreground placeholder:text-muted-foreground focus:outline-hidden"
/>
<span className="text-sm text-muted-foreground">{field.unit}</span>
</div>
<InheritNote draft={draft} own={own} everyone={describeCap(everyone, field.unit)} onReset={() => draft.clearOrgValue('devices', field.key)} />
</div>
</SettingItem>
);
})}
<SettingItem
label="Don't keep attachments or files"
description="Mail stays on the device without its attachments, and files kept for offline use are removed. They open from the server when there is a connection."
>
<div className="flex flex-col items-start gap-1 sm:items-end">
<ToggleSwitch checked={noAttachments.value} onChange={(v) => draft.setOrgValue('devices', 'noOfflineAttachments', v)} testId="org-devices-noOfflineAttachments" />
<InheritNote draft={draft} own={noAttachments.own} everyone={noAttachments.everyone ? 'on' : 'off'} onReset={() => draft.clearOrgValue('devices', 'noOfflineAttachments')} />
</div>
</SettingItem>
<p className="pt-2 text-xs text-muted-foreground">
What a device keeps: the last 30 days of the Inbox and of the folders the person opens, at most 250 MB, encrypted in
the browser. That protects against someone copying the browser&apos;s files, not against someone using the unlocked
browser. Every sign-out, the automatic one after inactivity included, deletes it. With several accounts signed in,
each account follows its own setting. Bulwark Lite has no offline mode.{' '}
What a device keeps: mail in every folder from the window the person picks (30 days, 90 days, a year or everything),
with calendar, contacts and files, encrypted on the device. That protects against someone copying its files, not
against someone using the unlocked browser or app. The limits above can only lower what people pick: where several
scopes apply, the lowest value counts, so a scope can&apos;t raise what another one lowered. The app holds itself to
them when it fetches the policy; the server can&apos;t check what is on a device. Every sign-out, the automatic one
after inactivity included, deletes everything. With several accounts signed in, each account follows its own settings.
Bulwark Lite reads the same policy but has no offline mode.{' '}
<a href={DOCS_URL} target="_blank" rel="noopener noreferrer" className="font-medium underline underline-offset-2">
How offline mode works
</a>
@@ -0,0 +1,224 @@
// A crypto plugin's render can be a key operation: with a smart card a dialog, and two
// PIN cancels in a row lock the key. So the render hook runs once per message, not once
// per object: mark-read replaces the object and changes nothing the hook gets to see.
// And a plugin can keep external content off for a body it can't vouch for (EFAIL).
import { act, render, renderHook, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Email, Mailbox } from '@/lib/jmap/types';
import { useSettingsStore } from '@/stores/settings-store';
vi.mock('@/hooks/use-media-query', () => ({
useDeviceDetection: () => ({ isMobile: false, isTablet: false, isDesktop: true }),
useMediaQuery: () => false,
useIsDesktop: () => true,
}));
vi.mock('@/components/tour/tour-provider', () => ({
useTour: () => ({ activeTour: null, startTour: vi.fn(), endTour: vi.fn(), registerStep: vi.fn() }),
}));
vi.mock('@/components/plugins/plugin-slot', () => ({ PluginSlot: () => null }));
vi.mock('@/hooks/use-plugin-slot-offers', () => ({ usePluginSlotOffers: () => [] }));
class NoopResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', NoopResizeObserver);
const { renderHooks, clearAllHooks } = await import('@/lib/plugin-hooks');
const { usePluginRenderedBody, pluginRenderKey } = await import('@/hooks/use-plugin-rendered-body');
const { EmailViewer } = await import('../email-viewer');
const { ConversationEmailBody } = await import('../thread-conversation-view');
const REMOTE = 'https://tracker.example/x.png';
// The blocker keeps the address in an inert attribute, for "Load images". What matters is that it isn't a live one.
const LOADS_REMOTE = /<img[^>]*\ssrc="https:\/\/tracker/;
const mailboxes = [{ id: 'inbox', name: 'Inbox', role: 'inbox', parentId: null, myRights: { mayAddItems: true } }] as unknown as Mailbox[];
const message = (over: Partial<Email> = {}) => ({
id: 'e1',
blobId: 'b1',
threadId: 't1',
mailboxIds: { inbox: true },
keywords: {},
from: [{ name: 'Alice', email: 'alice@example.com' }],
to: [{ name: 'Bob', email: 'bob@example.com' }],
subject: 'Hello',
receivedAt: '2026-01-01T00:00:00Z',
preview: 'Hello',
hasAttachment: false,
size: 100,
textBody: [{ partId: '1', type: 'text/plain' }],
htmlBody: [],
attachments: [],
bodyStructure: { type: 'application/pkcs7-mime' },
headers: { 'content-type': 'application/pkcs7-mime; smime-type=enveloped-data' },
bodyValues: { '1': { value: 'Hello Bob', isTruncated: false } },
...over,
}) as unknown as Email;
const renders = () => {
const handler = vi.fn(async () => ({ html: '', text: 'Decrypted', attachments: [], handledBy: 'smime' }));
renderHooks.onRenderEmailBody.register('smime', handler as never);
return handler;
};
const openViewer = (email: Email) => <EmailViewer email={email} mailboxes={mailboxes} />;
const originalDelay = useSettingsStore.getState().markAsReadDelay;
const originalPolicy = useSettingsStore.getState().externalContentPolicy;
beforeEach(() => {
useSettingsStore.setState({ markAsReadDelay: -1 });
});
afterEach(() => {
clearAllHooks();
useSettingsStore.setState({ markAsReadDelay: originalDelay, externalContentPolicy: originalPolicy });
});
describe('pluginRenderKey', () => {
it('ignores what the hook does not get: keywords and mailboxes', () => {
expect(pluginRenderKey(message({ keywords: { $seen: true } }))).toBe(pluginRenderKey(message()));
expect(pluginRenderKey(message({ mailboxIds: { archive: true } }))).toBe(pluginRenderKey(message()));
});
it('changes with the message and its blob', () => {
expect(pluginRenderKey(message({ id: 'e2' }))).not.toBe(pluginRenderKey(message()));
expect(pluginRenderKey(message({ blobId: 'b2' }))).not.toBe(pluginRenderKey(message()));
});
it('changes when the list row becomes the full message, which is what the hook decides on', () => {
const row = message({ headers: undefined, bodyStructure: undefined, bodyValues: undefined });
expect(pluginRenderKey(message())).not.toBe(pluginRenderKey(row));
});
it('changes when a truncated body arrives whole', () => {
const whole = message({ bodyValues: { '1': { value: 'Hello Bob, and the rest', isTruncated: false } } as never });
expect(pluginRenderKey(whole)).not.toBe(pluginRenderKey(message()));
});
});
describe('usePluginRenderedBody', () => {
it('runs the hook once when mark-read replaces the message object', async () => {
const handler = renders();
const { result, rerender } = renderHook(({ email }) => usePluginRenderedBody(email), { initialProps: { email: message() } });
await waitFor(() => expect(result.current.text).toBe('Decrypted'));
rerender({ email: message({ keywords: { $seen: true } }) });
await act(async () => {});
expect(handler).toHaveBeenCalledTimes(1);
expect(result.current.text).toBe('Decrypted');
});
it('runs it again for an asked-for render, and for another message', async () => {
const handler = renders();
const { rerender } = renderHook(({ email }) => usePluginRenderedBody(email), { initialProps: { email: message() } });
await waitFor(() => expect(handler).toHaveBeenCalledTimes(1));
await act(async () => { window.dispatchEvent(new CustomEvent('plugin:rerender-email')); });
await waitFor(() => expect(handler).toHaveBeenCalledTimes(2));
rerender({ email: message({ id: 'e2', blobId: 'b2' }) });
await waitFor(() => expect(handler).toHaveBeenCalledTimes(3));
});
it('says when the plugin wants external content off', async () => {
renderHooks.onRenderEmailBody.register('smime', (async () => ({
html: '<p>Body</p>', text: '', attachments: [], handledBy: 'smime', blockExternalContent: true,
})) as never);
const { result } = renderHook(() => usePluginRenderedBody(message()));
await waitFor(() => expect(result.current.blockExternal).toBe(true));
});
it('leaves it to the settings otherwise', async () => {
const handler = renders();
const { result } = renderHook(() => usePluginRenderedBody(message()));
await waitFor(() => expect(handler).toHaveBeenCalled());
await waitFor(() => expect(result.current.text).toBe('Decrypted'));
expect(result.current.blockExternal).toBe(false);
});
});
describe('EmailViewer', () => {
it('runs the hook once when mark-read replaces the message object', async () => {
const handler = renders();
const { rerender } = render(openViewer(message()));
await screen.findByText('Decrypted');
rerender(openViewer(message({ keywords: { $seen: true } })));
await act(async () => {});
expect(handler).toHaveBeenCalledTimes(1);
expect(screen.getByText('Decrypted')).toBeTruthy();
}, 30_000);
it('runs it again when the list row becomes the full message', async () => {
const handler = renders();
const { rerender } = render(openViewer(message({ headers: undefined, bodyStructure: undefined })));
await waitFor(() => expect(handler).toHaveBeenCalledTimes(1));
rerender(openViewer(message()));
await waitFor(() => expect(handler).toHaveBeenCalledTimes(2));
}, 30_000);
it('runs it again for an asked-for render', async () => {
const handler = renders();
render(openViewer(message()));
await waitFor(() => expect(handler).toHaveBeenCalledTimes(1));
await act(async () => { window.dispatchEvent(new CustomEvent('plugin:rerender-email')); });
await waitFor(() => expect(handler).toHaveBeenCalledTimes(2));
}, 30_000);
const remoteBody = (blockExternalContent?: boolean) => {
renderHooks.onRenderEmailBody.register('smime', (async () => ({
html: `<p>Secret body</p><img src="${REMOTE}">`, text: '', attachments: [], handledBy: 'smime',
...(blockExternalContent ? { blockExternalContent } : {}),
})) as never);
};
const frameDoc = (container: HTMLElement) => container.querySelector('iframe')?.getAttribute('srcdoc') ?? '';
it('keeps external content off when the plugin says so, whatever the settings allow', async () => {
useSettingsStore.setState({ externalContentPolicy: 'allow' });
remoteBody(true);
const { container } = render(openViewer(message()));
await waitFor(() => expect(frameDoc(container)).toContain('Secret body'));
expect(frameDoc(container)).not.toMatch(LOADS_REMOTE);
}, 30_000);
it('offers no way to load it then', async () => {
useSettingsStore.setState({ externalContentPolicy: 'ask' });
remoteBody(true);
const { container } = render(openViewer(message()));
await waitFor(() => expect(frameDoc(container)).toContain('Secret body'));
await act(async () => {});
expect(screen.queryByTestId('external-content-notice')).toBeNull();
}, 30_000);
it('follows the settings for any other plugin body', async () => {
useSettingsStore.setState({ externalContentPolicy: 'allow' });
remoteBody();
const { container } = render(openViewer(message()));
await waitFor(() => expect(frameDoc(container)).toContain('Secret body'));
expect(frameDoc(container)).toMatch(LOADS_REMOTE);
}, 30_000);
});
describe('ConversationEmailBody', () => {
const body = (allowExternal: boolean, onAllowExternal = () => {}) => (
<ConversationEmailBody email={message()} allowExternal={allowExternal} onAllowExternal={onAllowExternal} />
);
it('keeps external content off when the plugin says so, even with it allowed', async () => {
renderHooks.onRenderEmailBody.register('smime', (async () => ({
html: `<p>Secret body</p><img src="${REMOTE}">`, text: '', attachments: [], handledBy: 'smime', blockExternalContent: true,
})) as never);
const { container } = render(body(true));
await waitFor(() => expect(container.querySelector('iframe')?.getAttribute('srcdoc') ?? '').toContain('Secret body'));
expect(container.querySelector('iframe')?.getAttribute('srcdoc')).not.toMatch(LOADS_REMOTE);
}, 30_000);
it('has no button to load it', async () => {
renderHooks.onRenderEmailBody.register('smime', (async () => ({
html: `<p>Secret body</p><img src="${REMOTE}">`, text: '', attachments: [], handledBy: 'smime', blockExternalContent: true,
})) as never);
const { container } = render(body(false));
await waitFor(() => expect(container.querySelector('iframe')?.getAttribute('srcdoc') ?? '').toContain('Secret body'));
await act(async () => {});
expect(screen.queryByRole('button', { name: /load_external_content|load/i })).toBeNull();
}, 30_000);
});
+19 -5
View File
@@ -127,6 +127,7 @@ import type { Attachment as PostalMimeAttachment } from 'postal-mime';
import { useSettingsStore } from "@/stores/settings-store";
import { useShallow } from "zustand/react/shallow";
import { useEffectiveMailLayout } from "@/hooks/use-effective-mail-layout";
import { pluginRenderKey } from "@/hooks/use-plugin-rendered-body";
import { registerComposeFlush } from "@/lib/compose-flush-registry";
import { useExternalRecipientConfirmStore } from "@/stores/external-recipient-confirm-store";
import { useUIStore } from "@/stores/ui-store";
@@ -1794,6 +1795,9 @@ export function EmailViewer({
const [pluginRenderedHtml, setPluginRenderedHtml] = useState<string | null>(null);
const [pluginRenderedText, setPluginRenderedText] = useState<string | null>(null);
const [pluginRenderedAttachments, setPluginRenderedAttachments] = useState<PostalMimeAttachment[]>(NO_POSTAL_ATTACHMENTS);
// The plugin can't vouch for its body (mail with no integrity protection): external
// content stays off, whatever the settings or a trusted sender allow.
const [pluginBlocksExternal, setPluginBlocksExternal] = useState(false);
// Bumped when a plugin calls `api.ui.rerenderEmail` (e.g. the S/MIME plugin
// after the user unlocks a key from the banner) to force the onRenderEmailBody
// hook to run again for the open message so the body re-decrypts.
@@ -2266,6 +2270,7 @@ export function EmailViewer({
setPluginRenderedHtml(null);
setPluginRenderedText(null);
setPluginRenderedAttachments(NO_POSTAL_ATTACHMENTS);
setPluginBlocksExternal(false);
setTnefHtml(null);
setTnefText(null);
setTnefAttachments(NO_TNEF_ATTACHMENTS);
@@ -2287,6 +2292,7 @@ export function EmailViewer({
setPluginRenderedHtml(null);
setPluginRenderedText(null);
setPluginRenderedAttachments(NO_POSTAL_ATTACHMENTS);
setPluginBlocksExternal(false);
return;
}
let cancelled = false;
@@ -2328,14 +2334,17 @@ export function EmailViewer({
text?: string;
attachments?: Array<{ name?: string; type?: string; size?: number; dataUrl?: string; cid?: string }>;
handledBy?: string;
blockExternalContent?: boolean;
};
if (cancelled) return;
if (!result || !result.handledBy) {
setPluginRenderedHtml(null);
setPluginRenderedText(null);
setPluginRenderedAttachments(NO_POSTAL_ATTACHMENTS);
setPluginBlocksExternal(false);
return;
}
setPluginBlocksExternal(result.blockExternalContent === true);
setPluginRenderedHtml(typeof result.html === 'string' && result.html ? result.html : null);
setPluginRenderedText(typeof result.text === 'string' && result.text ? result.text : null);
// Normalise the plugin's attachment shape into the PostalMime-like shape
@@ -2354,11 +2363,15 @@ export function EmailViewer({
setPluginRenderedHtml(null);
setPluginRenderedText(null);
setPluginRenderedAttachments(NO_POSTAL_ATTACHMENTS);
setPluginBlocksExternal(false);
}
})();
return () => { cancelled = true; };
}, [email, pluginRenderNonce]);
// Once per message, not per object: a render can be a key operation, and mark-read
// replaces the object. pluginRenderKey says what should run this again.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pluginRenderKey(email), pluginRenderNonce]);
// TNEF (winmail.dat) detection and processing
useEffect(() => {
@@ -2704,7 +2717,7 @@ export function EmailViewer({
// 'ask' = block until the user allows this message or trusts the sender.
const shouldBlockExternal = useMemo(() => {
if (!email) return false;
if (remoteHeldBack) return true;
if (remoteHeldBack || pluginBlocksExternal) return true;
const senderEmail = email.from?.[0]?.email?.toLowerCase();
const senderIsTrusted = senderEmail
? isSenderTrusted(senderEmail) || (trustedSendersAddressBook && isTrustedAddressBookSender(senderEmail))
@@ -2717,7 +2730,7 @@ export function EmailViewer({
// permission changes, so they're deliberately omitted from deps (matches
// the srcDoc rebuild contract described on `emailContent`).
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [email, externalContentPolicy, allowExternalContent, remoteHeldBack]);
}, [email, externalContentPolicy, allowExternalContent, remoteHeldBack, pluginBlocksExternal]);
// Reported as phishing ($phishing): a banner, its links turned off and
// no cards (a phishing mail's "booking" is the lure).
@@ -5684,7 +5697,7 @@ ${printWatermark ? printWatermark.html : ''}
{/* Unified Notification Banner - External Content + Calendar Invitation + Read Receipt */}
{(senderVerification ||
(!plainView && hasBlockedContent && (remoteBlockedByOrg || (!allowExternalContent && externalContentPolicy !== 'allow'))) ||
(!plainView && !pluginBlocksExternal && hasBlockedContent && (remoteBlockedByOrg || (!allowExternalContent && externalContentPolicy !== 'allow'))) ||
hasCalendarInvitation ||
(readReceiptResponse === 'ask' && shouldOfferReadReceipt) ||
structuredCards.length > 0) && (
@@ -5717,7 +5730,8 @@ ${printWatermark ? printWatermark.html : ''}
{/* External Content Controls */}
{/* shouldBlockExternal: gone once trust applies (e.g. the
policy finished loading after the first paint). */}
{!plainView && hasBlockedContent && shouldBlockExternal && (remoteBlockedByOrg || (!allowExternalContent && externalContentPolicy !== 'allow')) && (
{/* A plugin's own banner says why it keeps external content off, and there's nothing to load. */}
{!plainView && !pluginBlocksExternal && hasBlockedContent && shouldBlockExternal && (remoteBlockedByOrg || (!allowExternalContent && externalContentPolicy !== 'allow')) && (
<div className="flex items-start gap-3 py-1" data-testid="external-content-notice">
<div className="w-10 h-10 rounded-full bg-info/15 text-info flex items-center justify-center flex-shrink-0 shadow-sm">
<Image className="w-5 h-5" />
@@ -549,7 +549,7 @@ interface ConversationEmailBodyProps {
*/
export function ConversationEmailBody({
email,
allowExternal,
allowExternal: externalAllowed,
onAllowExternal,
onTrustSender,
onDownloadAttachment,
@@ -602,6 +602,8 @@ export function ConversationEmailBody({
const cidBlobUrls = useMemo(() => cidResult?.request === cidRequest ? cidResult.urls : {}, [cidResult, cidRequest]);
// A crypto plugin's decrypted body (PGP, S/MIME) replaces the message's own.
const pluginBody = usePluginRenderedBody(email);
// The plugin can't vouch for its body: external content stays off, whatever was allowed.
const allowExternal = externalAllowed && !pluginBody.blockExternal;
// reader-above-quote: the body the quote was collapsed in, once it is on screen.
const [htmlBodyEl, setHtmlBodyEl] = useState<HTMLElement | null>(null);
const [plainBodyEl, setPlainBodyEl] = useState<HTMLDivElement | null>(null);
@@ -930,7 +932,7 @@ export function ConversationEmailBody({
<div className={className}>
{phishingMarked && <PhishingMarkBanner className="px-4" />}
{/* External content warning */}
{hasBlockedContent && !allowExternal && emailContent.isHtml && (
{hasBlockedContent && !allowExternal && !pluginBody.blockExternal && emailContent.isHtml && (
<div className="px-4 py-2 bg-muted/50 flex items-center justify-between text-sm" data-testid="external-content-notice">
<span className="text-muted-foreground">
{remoteBlockedByOrg ? t("third_party.remote_content_blocked") : t("email_viewer.external_content_warning")}
+11 -1
View File
@@ -3,12 +3,13 @@
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
import { useTranslations } from "next-intl";
import {
Upload, FolderPlus, Download, Trash2, Pencil, Copy, Clipboard, Scissors, Info,
Upload, FolderPlus, Download, Trash2, Pencil, Copy, Clipboard, Scissors, Info, Pin, PinOff,
FilePlus, CopyPlus, AlertCircle, FolderUp, Share2, SquarePen, Eye, FolderOpen,
RefreshCw, Star, StarOff, Plus, CheckSquare, File, RotateCcw, FolderInput,
} from "@/components/icons";
import { useIsDesktop, useIsMobile, useMediaQuery } from "@/hooks/use-media-query";
import { useContextMenu } from "@/hooks/use-context-menu";
import { useKeepFileOffline } from "@/hooks/use-keep-file-offline";
import { useRegisterCommands } from "@/lib/command-registry";
import { formatShortcutCombo } from "@/lib/shortcut-keys";
import { Button } from "@/components/ui/button";
@@ -319,6 +320,8 @@ export function FileBrowser({
const dragDepth = useRef(0);
const anchorName = useRef<string | null>(null);
const menu = useContextMenu<MenuData>();
const tOffline = useTranslations("offlineStorage");
const { keptOffline, canKeepOffline, toggleKeptOffline } = useKeepFileOffline(client);
const fileInputRef = useRef<HTMLInputElement>(null);
const folderInputRef = useRef<HTMLInputElement>(null);
const searchInputRef = useRef<HTMLInputElement>(null);
@@ -939,6 +942,13 @@ export function FileBrowser({
)}
</>
)}
{canKeepOffline(resource) && (
<ContextMenuItem
icon={keptOffline.has(resource.id) ? PinOff : Pin}
label={keptOffline.has(resource.id) ? tOffline("unpin") : tOffline("keep_offline")}
onClick={() => { closeMenu(); void toggleKeptOffline(resource); }}
/>
)}
{(canShare(resource) || (resource.isDirectory && !resource.isShared)) && <ContextMenuSeparator />}
{canShare(resource) && (
<ContextMenuItem icon={Share2} label={t("share")} onClick={() => { closeMenu(); setShareTargetId(resource.id); }} />
+9 -1
View File
@@ -6,6 +6,8 @@ import { Check, Plus, LogOut, Mails, Star, ChevronDown, AlertCircle, GripVertica
import { useTranslations } from "next-intl";
import type { Mailbox } from "@/lib/jmap/types";
import { useAccountStore, type AccountEntry } from "@/stores/account-store";
import { useDesktopPolicy } from "@/lib/desktop/policy";
import { accountRefusedByDesktopPolicy } from "@/lib/desktop/refused-accounts";
import { useAuthStore } from "@/stores/auth-store";
import { useEmailStore } from "@/stores/email-store";
import { useSettingsStore } from "@/stores/settings-store";
@@ -95,7 +97,13 @@ export function AccountSwitcher({
const tCommon = useTranslations("common");
const ownMailboxes = useEmailStore((s) => s.mailboxes);
const accounts = useAccountStore((s) => s.accounts);
const storedAccounts = useAccountStore((s) => s.accounts);
const devicePolicy = useDesktopPolicy();
// One on a server the device's OS policy refuses is stopped, and isn't on offer here.
const accounts = useMemo(
() => (devicePolicy.AllowedJmapServers.value ? storedAccounts.filter((account) => !accountRefusedByDesktopPolicy(account, devicePolicy)) : storedAccounts),
[storedAccounts, devicePolicy],
);
const setDefaultAccount = useAccountStore((s) => s.setDefaultAccount);
const reorderAccounts = useAccountStore((s) => s.reorderAccounts);
// Read activeAccountId from authStore so the selector matches the actually-loaded
@@ -0,0 +1,347 @@
import 'fake-indexeddb/auto';
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const offline = vi.hoisted(() => ({
clearAccount: vi.fn(async () => {}),
disableOffline: vi.fn(async () => {}),
unsentCount: vi.fn(async () => ({ messages: 0, changes: 0 })),
chooseScope: vi.fn(),
requestPersistence: vi.fn(async () => 'refused'),
dismissAreaConflict: vi.fn(async () => {}),
}));
vi.mock('@/lib/offline/offline-mode', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/offline/offline-mode')>()),
clearAccount: offline.clearAccount,
disableOffline: offline.disableOffline,
unsentCount: offline.unsentCount,
chooseScope: offline.chooseScope,
requestPersistence: offline.requestPersistence,
dismissAreaConflict: offline.dismissAreaConflict,
readPersistence: vi.fn(async () => 'refused'),
refreshUsage: vi.fn(async () => {}),
}));
import { OfflineSettings } from '../offline-settings';
import { OfflineBanner } from '../offline-banner';
import { useOfflineStore } from '@/lib/offline/state';
import { useAccountStore } from '@/stores/account-store';
import { useAuthStore } from '@/stores/auth-store';
import { usePolicyStore } from '@/stores/policy-store';
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
import { setStoreBackend, type StoreBackend } from '@/lib/store/client/api';
import { fileRefFor } from '@/hooks/use-keep-file-offline';
const area = vi.fn(async (_account: string, name: string) => (name === 'files.kept' ? {
files: [
{ jmap: 'j', id: 'f1', name: 'Pinned.pdf', size: 2048, pinned: true, kept: true, error: null },
{ jmap: 'j', id: 'f2', name: 'Big.iso', size: 1048576, pinned: true, kept: false, error: 'overLimit' },
{ jmap: 'j', id: 'f3', name: 'Opened.txt', size: 5, pinned: false, kept: true, error: null },
],
} : null));
function setDevices(devices: Partial<typeof DEFAULT_ORGANISATION_POLICY.devices>) {
usePolicyStore.setState({
loaded: true, source: 'server', subject: { email: 'alice@example.org', groups: [] },
policy: { ...usePolicyStore.getState().policy, organisation: { ...DEFAULT_ORGANISATION_POLICY, devices: { ...DEFAULT_ORGANISATION_POLICY.devices, ...devices } } },
} as never);
}
beforeEach(() => {
localStorage.clear();
vi.clearAllMocks();
setStoreBackend({ supported: () => true, area, setScope: vi.fn(async () => {}) } as unknown as StoreBackend);
useAuthStore.setState({ activeAccountId: 'acc', getAllConnectedClients: (() => new Map()) as never });
useOfflineStore.setState({
enabled: ['acc'], outbox: [], pendingChanges: 0, conflicts: [], areaConflicts: [], syncing: [], lastSyncedAt: {}, choices: { acc: { on: true } }, health: {},
storageFull: {}, progress: {}, persist: 'granted', browserOffline: false, downServers: [], startedOffline: false,
usage: { acc: { bytes: 5000, parts: [{ part: 'mail', bytes: 4000, items: 12 }, { part: 'attachments', bytes: 1000, items: 2 }] } },
});
setDevices({});
});
afterEach(() => { setStoreBackend(null); });
const windowSelect = () => screen.getByText('window_label').closest('[data-search-label]')!.querySelector('select') as HTMLSelectElement;
describe('what an account keeps', () => {
it('offers every window and hands the choice on', () => {
render(<OfflineSettings />);
expect([...windowSelect().options].map((o) => o.value)).toEqual(['30', '90', '365', 'all']);
expect(windowSelect().value).toBe('90');
fireEvent.change(windowSelect(), { target: { value: 'all' } });
expect(offline.chooseScope).toHaveBeenCalledWith('acc', { days: null });
});
it('under a cap offers only what fits, and says who set it', () => {
setDevices({ offlineMaxDays: 60 });
render(<OfflineSettings />);
expect([...windowSelect().options].map((o) => o.value)).toEqual(['30', '60']);
expect(windowSelect().value).toBe('60');
expect(within(screen.getByText('window_label').closest('[data-search-label]') as HTMLElement).getByText('org_locked')).toBeInTheDocument();
});
it('locks the window when the cap leaves no choice', () => {
setDevices({ offlineMaxDays: 14 });
render(<OfflineSettings />);
const row = screen.getByText('window_label').closest('[data-search-label]') as HTMLElement;
expect(windowSelect().parentElement?.closest('[inert]')).not.toBeNull();
expect(within(row).getByText('org_locked')).toBeInTheDocument();
});
it('locks attachments off when the organisation forbids them, and hides the size', () => {
useOfflineStore.setState({ choices: { acc: { on: true, attachments: true } } });
setDevices({ noOfflineAttachments: true });
render(<OfflineSettings />);
const toggle = screen.getByTestId('offline-attachments');
expect(toggle.getAttribute('aria-checked')).toBe('false');
expect(toggle.closest('[inert]')).not.toBeNull();
expect(screen.queryByText('attachment_cap_label')).toBeNull();
fireEvent.click(toggle);
});
it('caps the size choice', () => {
setDevices({ offlineMaxAttachmentMb: 10 });
render(<OfflineSettings />);
const select = screen.getByText('attachment_cap_label').closest('[data-search-label]')!.querySelector('select') as HTMLSelectElement;
expect([...select.options].map((o) => o.value)).toEqual(['5', '10']);
fireEvent.change(select, { target: { value: '5' } });
expect(offline.chooseScope).toHaveBeenCalledWith('acc', { maxAttachmentBytes: 5 * 1024 * 1024 });
});
it('says when the total is set by the organisation', () => {
setDevices({ offlineMaxTotalMb: 100 });
render(<OfflineSettings />);
expect(screen.getByTestId('offline-total-locked').textContent).toContain('total_locked');
});
it('locks the switch when the desktop policy forces it', () => {
(window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, policy: { get: () => ({ OfflineEnabled: { value: true, locked: true, source: 'machine' } }) } };
render(<OfflineSettings />);
expect(screen.getByTestId('offline-switch').closest('[inert]')).not.toBeNull();
delete (window as { bulwarkDesktop?: unknown }).bulwarkDesktop;
});
it('shows nothing of this with offline off', () => {
useOfflineStore.setState({ enabled: [] });
render(<OfflineSettings />);
expect(screen.queryByText('window_label')).toBeNull();
expect(screen.queryByTestId('offline-clear')).toBeNull();
});
});
describe('what it takes', () => {
it('lists each part', () => {
render(<OfflineSettings />);
const usage = screen.getByTestId('offline-usage');
expect(usage.textContent).toContain('usage_mail');
expect(usage.textContent).toContain('usage_attachments');
expect(screen.getByTestId('offline-status').textContent).toContain('usage');
});
it('shows the time of the last sync from before a reload', () => {
useOfflineStore.setState({ health: { acc: { lastSync: Date.now() - 60_000 } } });
render(<OfflineSettings />);
expect(screen.getByTestId('offline-status').textContent).toContain('usage_updated');
});
it('announces the phase, not every number', () => {
const now = Date.now();
useOfflineStore.setState({ syncing: ['acc'], progress: { acc: { area: 'mail', phase: 'bodies', done: 500, total: 5000, startedAt: now, startedDone: 0, at: now } } });
render(<OfflineSettings />);
expect(screen.getByTestId('offline-progress-phase').textContent).toBe('progress_bodies');
expect(screen.getByTestId('offline-progress-phase').getAttribute('role')).toBe('status');
expect(screen.getByTestId('offline-progress').querySelectorAll('[aria-live]')).toHaveLength(1);
});
it('shows a large sync going on, with the time left once it can tell', () => {
const now = Date.now();
useOfflineStore.setState({
syncing: ['acc'],
progress: { acc: { area: 'mail', phase: 'bodies', done: 500, total: 5000, startedAt: now - 60_000, startedDone: 0, at: now } },
});
render(<OfflineSettings />);
const line = screen.getByTestId('offline-progress');
expect(line.textContent).toContain('progress_bodies');
expect(line.textContent).toContain('progress_count');
expect(line.textContent).toContain('progress_left');
expect(line.querySelector('progress')?.getAttribute('max')).toBe('5000');
});
it('keeps a small catch-up quiet', () => {
const now = Date.now();
useOfflineStore.setState({ syncing: ['acc'], progress: { acc: { area: 'mail', phase: 'bodies', done: 1, total: 3, startedAt: now, startedDone: 0, at: now } } });
render(<OfflineSettings />);
expect(screen.queryByTestId('offline-progress')).toBeNull();
});
});
describe('clearing an account', () => {
it('asks first, reading then what would be lost: messages and changes', async () => {
offline.unsentCount.mockResolvedValue({ messages: 1, changes: 3 });
render(<OfflineSettings />);
fireEvent.click(screen.getByTestId('offline-clear'));
expect(offline.clearAccount).not.toHaveBeenCalled();
expect(await screen.findByText(/unsent_changes/)).toBeInTheDocument();
expect(screen.getByText(/clear_confirm_unsent/)).toBeInTheDocument();
expect(offline.unsentCount).toHaveBeenCalledWith('acc');
fireEvent.click(screen.getByRole('button', { name: 'clear_confirm' }));
expect(offline.clearAccount).toHaveBeenCalledWith('acc');
});
it('says nothing is lost when nothing is', async () => {
offline.unsentCount.mockResolvedValue({ messages: 0, changes: 0 });
render(<OfflineSettings />);
fireEvent.click(screen.getByTestId('offline-clear'));
expect(await screen.findByText('clear_confirm_message')).toBeInTheDocument();
await waitFor(() => expect(offline.unsentCount).toHaveBeenCalled());
expect(screen.queryByText(/unsent_changes/)).toBeNull();
});
it('can\'t be asked for without a connection, since it downloads everything again', () => {
useOfflineStore.setState({ browserOffline: true });
render(<OfflineSettings />);
expect(screen.getByTestId('offline-clear')).toBeDisabled();
expect(screen.getByText('clear_needs_connection')).toBeInTheDocument();
});
it('nor after a start from the local copy', () => {
useOfflineStore.setState({ startedOffline: true });
render(<OfflineSettings />);
expect(screen.getByTestId('offline-clear')).toBeDisabled();
});
});
describe('the off switch', () => {
it('asks when only changes wait, naming them', async () => {
offline.unsentCount.mockResolvedValue({ messages: 0, changes: 2 });
render(<OfflineSettings />);
fireEvent.click(screen.getByTestId('offline-switch'));
expect(await screen.findByText(/unsent_changes/)).toBeInTheDocument();
expect(offline.disableOffline).not.toHaveBeenCalled();
});
it('asks nothing when nothing waits', async () => {
offline.unsentCount.mockResolvedValue({ messages: 0, changes: 0 });
render(<OfflineSettings />);
fireEvent.click(screen.getByTestId('offline-switch'));
await waitFor(() => expect(offline.disableOffline).toHaveBeenCalledWith('acc'));
});
});
describe('what the browser may do', () => {
it('warns when it refused to keep the data, and can ask again', async () => {
useOfflineStore.setState({ persist: 'refused' });
render(<OfflineSettings />);
expect(screen.getByTestId('offline-persist-refused').textContent).toContain('persist_refused');
await act(async () => { fireEvent.click(screen.getByTestId('offline-persist-retry')); });
expect(offline.requestPersistence).toHaveBeenCalled();
});
it('says nothing when it agreed', () => {
render(<OfflineSettings />);
expect(screen.queryByTestId('offline-persist-refused')).toBeNull();
});
it('warns about a store it deleted, with the count, until read', () => {
useOfflineStore.setState({ health: { acc: { reset: { at: 1, lost: 2 } } } });
render(<OfflineSettings />);
expect(screen.getByTestId('offline-evicted').textContent).toContain('evicted_lost');
fireEvent.click(within(screen.getByTestId('offline-evicted')).getByRole('button', { name: 'evicted_dismiss' }));
expect(useOfflineStore.getState().health.acc.reset).toBeUndefined();
});
it('says the device is full, with what to do', () => {
useOfflineStore.setState({ storageFull: { acc: 1 } });
render(<OfflineSettings />);
const alert = screen.getByTestId('offline-storage-full');
expect(alert.textContent).toContain('storage_full_title');
fireEvent.click(within(alert).getByRole('button', { name: 'clear_button' }));
expect(screen.getByRole('button', { name: 'clear_confirm' })).toBeInTheDocument();
});
it('gives the Safari hint only in Safari', () => {
const real = window.navigator.userAgent;
render(<OfflineSettings />);
expect(screen.queryByTestId('offline-safari-hint')).toBeNull();
Object.defineProperty(window.navigator, 'userAgent', { configurable: true, value: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15' });
render(<OfflineSettings />);
expect(screen.getByTestId('offline-safari-hint')).toBeInTheDocument();
Object.defineProperty(window.navigator, 'userAgent', { configurable: true, value: real });
});
});
describe('everything that did not go through', () => {
const conflicts = [
{ area: 'calendar', account: 'acc', id: 1, createdAt: 1, title: 'Team meeting', reason: 'changed', destroy: false, detail: null },
{ area: 'contacts', account: 'acc', id: 2, createdAt: 1, title: 'Ana Silva', reason: 'refused', destroy: false, detail: 'Nope' },
] as const;
it('lists calendar and contacts next to mail, and each can be dismissed', async () => {
useOfflineStore.setState({ areaConflicts: [...conflicts] });
render(<OfflineSettings />);
const rows = screen.getAllByTestId('offline-conflict-row');
expect(rows.map((row) => row.getAttribute('data-area'))).toEqual(['calendar', 'contacts']);
expect(rows[0].textContent).toContain('Team meeting');
fireEvent.click(within(rows[1]).getByRole('button', { name: 'dismiss_label' }));
await waitFor(() => expect(offline.dismissAreaConflict).toHaveBeenCalledWith(conflicts[1]));
});
it('counts them all in the banner', () => {
useOfflineStore.setState({ areaConflicts: [...conflicts] });
render(<OfflineBanner />);
expect(screen.getByTestId('offline-banner-conflicts')).toBeInTheDocument();
});
});
describe('for screen readers', () => {
it('names each account\'s group when there are several, and the usage list', () => {
useAuthStore.setState({ activeAccountId: 'acc', getAllConnectedClients: (() => new Map([['acc', {}], ['b', {}]])) as never });
useAccountStore.setState({ accounts: [{ id: 'acc', label: 'A', email: 'a@example.org' }, { id: 'b', label: 'B', email: 'b@example.org' }] as never });
render(<OfflineSettings />);
expect(screen.getByRole('group', { name: 'a@example.org' })).toBeInTheDocument();
expect(screen.getByRole('group', { name: 'b@example.org' })).toBeInTheDocument();
expect(screen.getByTestId('offline-usage').getAttribute('aria-label')).toBe('usage_label');
useAccountStore.setState({ accounts: [] });
});
});
describe('in the desktop app', () => {
afterEach(() => { delete (window as { bulwarkDesktop?: unknown }).bulwarkDesktop; });
it('says what the risk is there, and only there', () => {
render(<OfflineSettings />);
expect(screen.getByTestId('offline-warning').textContent).toBe('warning');
cleanup();
(window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1 };
render(<OfflineSettings />);
expect(screen.getAllByTestId('offline-warning').map((node) => node.textContent)).toEqual(['warning_desktop']);
});
});
describe('files kept offline', () => {
it('lists the pinned ones with their state, and can let go of one', async () => {
render(<OfflineSettings />);
const rows = await screen.findAllByTestId('offline-kept-file');
expect(rows).toHaveLength(2);
expect(rows[0].textContent).toContain('kept_state_kept');
expect(rows[1].textContent).toContain('kept_error_overLimit');
expect(screen.queryByText('Opened.txt')).toBeNull();
fireEvent.click(within(rows[0]).getByRole('button', { name: 'unpin_label' }));
await waitFor(() => expect(area).toHaveBeenCalledWith('acc', 'files.unpin', expect.objectContaining({ jmap: 'j', id: 'f1' })));
});
it('an unpin the store refuses leaves the list as it is, with no error thrown about', async () => {
render(<OfflineSettings />);
const rows = await screen.findAllByTestId('offline-kept-file');
area.mockImplementationOnce(async () => { throw new Error('gone'); });
fireEvent.click(within(rows[0]).getByRole('button', { name: 'unpin_label' }));
await waitFor(() => expect(area).toHaveBeenCalledWith('acc', 'files.unpin', expect.anything()));
expect(await screen.findAllByTestId('offline-kept-file')).toHaveLength(2);
});
it('a node of another account goes by that account', () => {
expect(fileRefFor('shared1:n7', 'own')).toEqual({ jmap: 'shared1', id: 'n7' });
expect(fileRefFor('n7', 'own')).toEqual({ jmap: 'own', id: 'n7' });
});
});
@@ -19,7 +19,7 @@ import { setStoreBackend, type StoreBackend } from '@/lib/store/client/api';
function setNoOffline(noOffline: boolean) {
usePolicyStore.setState({
loaded: true,
subject: null,
subject: { email: 'alice@example.org', groups: [] },
policy: { ...usePolicyStore.getState().policy, organisation: { ...DEFAULT_ORGANISATION_POLICY, devices: { ...DEFAULT_ORGANISATION_POLICY.devices, noOffline } } },
});
}
@@ -54,12 +54,12 @@ describe('Settings > Offline', () => {
expect(offline.enableOffline).toHaveBeenCalledWith('acc');
});
it('asks before turning off with unsent messages', () => {
it('asks before turning off with unsent messages', async () => {
useOfflineStore.setState({ enabled: ['acc'], outbox: [{ id: 1, account: 'acc', createdAt: Date.now(), subject: 'Hi', to: ['b@example.org'], status: 'waiting' }] });
render(<OfflineSettings />);
fireEvent.click(screen.getByTestId('offline-switch'));
expect(await screen.findByText('off_confirm_title')).toBeInTheDocument();
expect(offline.disableOffline).not.toHaveBeenCalled();
expect(screen.getByText('off_confirm_title')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'off_confirm' }));
expect(offline.disableOffline).toHaveBeenCalledWith('acc');
});
@@ -78,7 +78,7 @@ describe('Settings > Offline', () => {
expect(screen.queryByTestId('offline-switch')).toBeNull();
});
it('asks before turning off when a draft is only on this device, and counts both lists', () => {
it('asks before turning off when a draft is only on this device, and counts both lists', async () => {
useOfflineStore.setState({
enabled: ['acc'],
localDrafts: [{ account: 'acc', localId: 'local-1', jmap: 'j', subject: 'Only here', status: 'waiting', createdAt: 0 }],
@@ -86,8 +86,8 @@ describe('Settings > Offline', () => {
});
render(<OfflineSettings />);
fireEvent.click(screen.getByTestId('offline-switch'));
expect(await screen.findByText('off_confirm_title')).toBeInTheDocument();
expect(offline.disableOffline).not.toHaveBeenCalled();
expect(screen.getByText('off_confirm_title')).toBeInTheDocument();
useOfflineStore.setState({ localDrafts: [], outbox: [] });
});
+248
View File
@@ -0,0 +1,248 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { useFormatter, useTranslations } from 'next-intl';
import { Lock } from '@/components/icons';
import { SettingItem, Select, ToggleSwitch } from '@/components/settings/settings-section';
import { OrgLockedNote } from '@/components/settings/org-locked-note';
import { Button } from '@/components/ui/button';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { formatFileSize } from '@/lib/utils';
import { chooseScope, clearAccount, disableOffline, dismissStoreReset, enableOffline, scopeForAccount, unsentCount } from '@/lib/offline/offline-mode';
import { attachmentCapOptions, maxAttachmentMb, windowOptions } from '@/lib/offline/scope';
import { isOfflineNow, useOfflineStore } from '@/lib/offline/state';
import { minutesLeft, progressWorthShowing, type SyncProgress } from '@/lib/offline/sync-progress';
import { devicesForAccount } from '@/lib/device-policy';
import { useAccountStore } from '@/stores/account-store';
import { KeptFiles } from './kept-files';
const MB = 1024 * 1024;
const ALL = 'all';
function ProgressLine({ progress }: { progress: SyncProgress }) {
const t = useTranslations('offlineStorage');
const format = useFormatter();
const phase = progress.phase === 'list' ? t('progress_list')
: progress.phase === 'bodies' ? t('progress_bodies')
: progress.phase === 'threads' ? t('progress_threads')
: progress.area === 'files' ? t('progress_files')
: t('progress_other');
const left = minutesLeft(progress);
const count = progress.total === null
? t('progress_count_open', { done: format.number(progress.done) })
: t('progress_count', { done: format.number(progress.done), total: format.number(progress.total) });
return (
<div className="space-y-1" data-testid="offline-progress">
{/* Only the phase is announced: the numbers change every moment. */}
<p className="sr-only" role="status" aria-live="polite" data-testid="offline-progress-phase">{phase}</p>
<p className="text-xs text-muted-foreground">
{phase}: {count}{left !== null && <> · {t('progress_left', { minutes: left })}</>}
</p>
{progress.total !== null && progress.total > 0 && (
<progress className="h-1 w-full overflow-hidden rounded-full [&::-webkit-progress-bar]:bg-muted [&::-webkit-progress-value]:bg-primary [&::-moz-progress-bar]:bg-primary" value={Math.min(progress.done, progress.total)} max={progress.total} aria-label={phase} />
)}
</div>
);
}
/** What a clear or switching off would lose, read when the question is asked. */
function useUnsent(account: string) {
const [unsent, setUnsent] = useState({ messages: 0, changes: 0 });
const live = useRef(true);
useEffect(() => {
live.current = true;
return () => { live.current = false; };
}, []);
const read = async () => {
const counted = await unsentCount(account);
if (live.current) setUnsent(counted);
return counted;
};
return { unsent, read };
}
/** One account in Settings > Backup & reset > Offline. */
export function AccountStorage({ account, primary, heading }: { account: string; primary: boolean; heading: string | null }) {
const t = useTranslations('offline.settings');
const tStorage = useTranslations('offlineStorage');
const format = useFormatter();
const enabled = useOfflineStore((s) => s.enabled.includes(account));
const usage = useOfflineStore((s) => s.usage[account]);
const syncing = useOfflineStore((s) => s.syncing.includes(account));
const lastSynced = useOfflineStore((s) => s.lastSyncedAt[account] ?? s.health[account]?.lastSync);
const storeReset = useOfflineStore((s) => s.health[account]?.reset);
const storageFull = useOfflineStore((s) => !!s.storageFull[account]);
const progress = useOfflineStore((s) => s.progress[account]);
const server = useAccountStore((s) => s.accounts.find((entry) => entry.id === account)?.serverUrl);
const address = useAccountStore((s) => {
const entry = s.accounts.find((candidate) => candidate.id === account);
return entry?.email || entry?.username || account;
});
// Clearing downloads everything again, which can't happen without the server.
const cantClear = useOfflineStore((s) => s.startedOffline || s.browserOffline || (!!server && isOfflineNow(server)));
// Re-renders on a choice; the value itself is worked out below, with the policy.
useOfflineStore((s) => s.choices[account]);
const { unsent, read: readUnsent } = useUnsent(account);
const [confirmOff, setConfirmOff] = useState(false);
const [confirmClear, setConfirmClear] = useState(false);
const devices = devicesForAccount(account);
const { scope, locked } = scopeForAccount(account);
const [now, setNow] = useState(() => Date.now());
useEffect(() => { setNow(Date.now()); }, [lastSynced, syncing]);
const testId = (id: string) => (primary ? id : `${id}-other`);
if (locked.offline) {
return (
<div className="py-3" data-testid={testId('offline-forbidden')}>
{heading && <p className="mb-1 text-sm font-semibold text-foreground [overflow-wrap:anywhere]">{heading}</p>}
<p className="text-sm font-medium text-foreground">{t('keep_label')}</p>
<p className="mt-1 text-xs text-muted-foreground">{t('forbidden')}</p>
<OrgLockedNote className="mt-1" />
</div>
);
}
const windows = windowOptions(devices.offlineMaxDays);
const windowLabel = (days: number | null) => {
if (days === null) return tStorage('window_everything');
if (days === 365) return tStorage('window_year');
return tStorage('window_days', { days });
};
const caps = attachmentCapOptions(devices.offlineMaxAttachmentMb);
const currentCap = maxAttachmentMb(scope);
const lostText = [
unsent.messages > 0 ? tStorage('clear_confirm_unsent', { count: unsent.messages }) : null,
unsent.changes > 0 ? tStorage('unsent_changes', { count: unsent.changes }) : null,
].filter(Boolean).join(' ');
const askClear = () => {
setConfirmClear(true);
void readUnsent();
};
const turnOff = async () => {
const counted = await readUnsent();
if (counted.messages + counted.changes > 0) setConfirmOff(true);
else void disableOffline(account);
};
return (
<div
className={heading ? 'rounded-md border border-border px-3' : undefined}
role={heading ? 'group' : undefined}
aria-label={heading ?? undefined}
data-testid={testId('offline-account')}
>
{heading && <p className="pt-3 text-sm font-semibold text-foreground [overflow-wrap:anywhere]">{heading}</p>}
<SettingItem label={t('keep_label')} description={tStorage('keep_description')} locked={locked.forced}>
<ToggleSwitch
checked={enabled}
testId={testId('offline-switch')}
onChange={(on) => {
if (on) void enableOffline(account);
else void turnOff();
}}
/>
</SettingItem>
{storeReset && (
<div className="my-2 flex flex-col gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-900 dark:text-amber-200 sm:flex-row sm:items-center sm:justify-between" role="alert" data-testid="offline-evicted">
<p className="min-w-0">
{tStorage('evicted')}
{storeReset.lost > 0 && <> {tStorage('evicted_lost', { count: storeReset.lost })}</>}
</p>
<Button variant="outline" size="sm" className="shrink-0" onClick={() => dismissStoreReset(account)}>{tStorage('evicted_dismiss')}</Button>
</div>
)}
{enabled && (
<>
{storageFull && (
<div className="my-2 flex flex-col gap-2 rounded-md border border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-foreground sm:flex-row sm:items-center sm:justify-between" role="alert" data-testid="offline-storage-full">
<p className="min-w-0"><span className="font-medium">{tStorage('storage_full_title')}</span> {tStorage('storage_full_text')}</p>
<Button variant="outline" size="sm" className="shrink-0" disabled={cantClear} onClick={askClear}>{tStorage('clear_button')}</Button>
</div>
)}
<div className="space-y-2 py-3">
<p className="text-xs text-muted-foreground" role="status" aria-live="polite" data-testid={testId('offline-status')}>
{syncing
? t('syncing')
: lastSynced
? t('usage_updated', { size: formatFileSize(usage?.bytes ?? 0), time: format.relativeTime(lastSynced, now) })
: t('usage', { size: formatFileSize(usage?.bytes ?? 0) })}
</p>
{syncing && progressWorthShowing(progress) && <ProgressLine progress={progress} />}
{usage && usage.parts.length > 0 && (
<dl className="grid grid-cols-[1fr_auto] gap-x-4 gap-y-1 text-xs" aria-label={tStorage('usage_label')} data-testid={testId('offline-usage')}>
{usage.parts.filter((part) => part.items > 0).map((part) => (
<div key={part.part} className="contents" data-part={part.part}>
<dt className="min-w-0 text-muted-foreground">{tStorage(`usage_${part.part}`)}</dt>
<dd className="text-end tabular-nums text-foreground">{tStorage('usage_row', { size: formatFileSize(part.bytes), count: part.items })}</dd>
</div>
))}
</dl>
)}
</div>
<SettingItem label={tStorage('window_label')} description={tStorage('window_description')} locked={windows.length <= 1}>
<div className="flex flex-col items-start gap-1 sm:items-end">
<Select
value={scope.days === null ? ALL : String(scope.days)}
onChange={(value) => chooseScope(account, { days: value === ALL ? null : Number(value) })}
options={windows.map((days) => ({ value: days === null ? ALL : String(days), label: windowLabel(days) }))}
/>
{locked.days && windows.length > 1 && <OrgLockedNote />}
</div>
</SettingItem>
<SettingItem
label={tStorage('attachments_label')}
description={tStorage('attachments_description', { total: formatFileSize(scope.maxTotalBytes) })}
locked={locked.attachments}
>
<ToggleSwitch
checked={scope.attachments}
testId={testId('offline-attachments')}
onChange={(on) => chooseScope(account, { attachments: on })}
/>
</SettingItem>
{locked.maxTotal && !locked.attachments && (
<p className="flex items-center gap-1 pb-2 text-xs text-muted-foreground" data-testid={testId('offline-total-locked')}>
<Lock className="h-3 w-3 shrink-0" aria-hidden="true" />
{tStorage('total_locked', { size: formatFileSize(scope.maxTotalBytes) })}
</p>
)}
{scope.attachments && (
<SettingItem label={tStorage('attachment_cap_label')} nested locked={caps.length <= 1}>
<div className="flex flex-col items-start gap-1 sm:items-end">
<Select
value={String(currentCap)}
onChange={(value) => chooseScope(account, { maxAttachmentBytes: Number(value) * MB })}
options={(caps.includes(currentCap) ? caps : [...caps, currentCap].sort((a, b) => a - b)).map((mb) => ({ value: String(mb), label: formatFileSize(mb * MB) }))}
/>
{locked.maxAttachment && caps.length > 1 && <OrgLockedNote />}
</div>
</SettingItem>
)}
<KeptFiles account={account} />
<SettingItem label={tStorage('clear_label')} description={cantClear ? tStorage('clear_needs_connection') : tStorage('clear_description')}>
<Button variant="outline" size="sm" disabled={cantClear} onClick={askClear} data-testid={testId('offline-clear')}>{tStorage('clear_button')}</Button>
</SettingItem>
</>
)}
<ConfirmDialog
isOpen={confirmOff}
onClose={() => setConfirmOff(false)}
onConfirm={() => { setConfirmOff(false); void disableOffline(account); }}
title={t('off_confirm_title')}
message={`${tStorage('off_confirm_text', { account: address })} ${lostText}`.trim()}
confirmText={t('off_confirm')}
variant="destructive"
/>
<ConfirmDialog
isOpen={confirmClear}
onClose={() => setConfirmClear(false)}
onConfirm={() => { setConfirmClear(false); void clearAccount(account); }}
title={tStorage('clear_confirm_title')}
message={`${tStorage('clear_confirm_message', { account: address })} ${lostText}`.trim()}
confirmText={tStorage('clear_confirm')}
variant="destructive"
/>
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
'use client';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button';
import { formatFileSize } from '@/lib/utils';
import { useKeptFiles } from '@/hooks/use-kept-files';
/** The files this account keeps until told otherwise, in Settings > Backup & reset > Offline. */
export function KeptFiles({ account }: { account: string }) {
const t = useTranslations('offlineStorage');
const { pinned, unpin } = useKeptFiles(account);
if (pinned.length === 0) return null;
return (
<div className="border-b border-border py-3 last:border-0" data-testid="offline-kept-files">
<h4 className="text-sm font-medium text-foreground">{t('kept_files_title')}</h4>
<ul className="mt-2 divide-y divide-border rounded-md border border-border" aria-label={t('kept_files_title')}>
{pinned.map((file) => (
<li key={`${file.jmap}:${file.id}`} className="flex flex-col gap-2 px-3 py-2 sm:flex-row sm:items-center sm:justify-between" data-testid="offline-kept-file" data-kept={file.kept}>
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-foreground">{file.name}</p>
<p className="text-xs text-muted-foreground">
{file.size !== null && <>{formatFileSize(file.size)} · </>}
{file.kept ? t('kept_state_kept') : file.error ? t(`kept_error_${file.error}` as 'kept_error_download') : t('kept_state_waiting')}
</p>
</div>
<Button variant="outline" size="sm" className="shrink-0" aria-label={t('unpin_label', { name: file.name })} onClick={() => void unpin(file)}>{t('unpin')}</Button>
</li>
))}
</ul>
</div>
);
}
+1 -1
View File
@@ -24,7 +24,7 @@ export function OfflineBanner() {
const browserOffline = useOfflineStore((s) => s.browserOffline || s.startedOffline);
const serverDown = useOfflineStore((s) => !!server && s.downServers.includes(serverKey(server)));
const waiting = useOfflineStore((s) => s.outbox.length);
const conflicts = useOfflineStore((s) => s.conflicts.length);
const conflicts = useOfflineStore((s) => s.conflicts.length + s.areaConflicts.length);
const refusedDrafts = useOfflineStore((s) => s.localDrafts.filter((draft) => draft.status === 'refused').length);
const waitingDrafts = useOfflineStore((s) => s.localDrafts.filter((draft) => draft.status !== 'refused').length);
const tConflicts = useTranslations('offlineConflicts');
+19 -3
View File
@@ -2,10 +2,12 @@
import { useEffect, useRef } from 'react';
import { useTranslations } from 'next-intl';
import { useRouter } from '@/i18n/navigation';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { useConfirmDialog } from '@/hooks/use-confirm-dialog';
import { addSignOutGate } from '@/lib/sign-out-cleanup';
import { reloadToNewBuild } from '@/lib/app-update';
import { buildSettingsPath } from '@/lib/deep-links';
import { confirmLoss, setOfflineNotifier, startOfflineMode, unsentCount, type OfflineNotice } from '@/lib/offline/offline-mode';
import { toast } from '@/stores/toast-store';
@@ -22,6 +24,12 @@ export function OfflineHost() {
const tConflicts = useTranslations('offlineConflicts');
const tConflictsRef = useRef(tConflicts);
tConflictsRef.current = tConflicts;
const tStorage = useTranslations('offlineStorage');
const tStorageRef = useRef(tStorage);
tStorageRef.current = tStorage;
const router = useRouter();
const routerRef = useRef(router);
routerRef.current = router;
const tDrafts = useTranslations('offlineDrafts');
const tDraftsRef = useRef(tDrafts);
tDraftsRef.current = tDrafts;
@@ -32,6 +40,7 @@ export function OfflineHost() {
useEffect(() => {
const show = (notice: OfflineNotice) => {
const tr = tRef.current;
const openStorage = { label: tStorageRef.current('open_settings'), onClick: () => routerRef.current.push(buildSettingsPath('backup', 'offline')) };
switch (notice.kind) {
case 'queued':
toast.info(tr('toast.queued'));
@@ -62,13 +71,20 @@ export function OfflineHost() {
toast.warning(tDraftsRef.current('toast_conflict', { subject: notice.subject || tr('outbox.no_subject') }));
break;
case 'storage-failed':
toast.warning(tConflictsRef.current('storage_failed'), { duration: 0 });
toast.warning(tStorageRef.current('storage_full_title'), { message: tStorageRef.current('storage_full_text'), duration: 0, action: openStorage });
break;
case 'default-on':
toast.info(tStorageRef.current('default_on'), { duration: 0, action: openStorage });
break;
case 'legacy-stranded':
toast.warning(tConflictsRef.current('legacy_stranded'), { duration: 0 });
break;
case 'store-reset':
toast.warning(tConflictsRef.current('store_reset'), { duration: 0 });
toast.warning(tConflictsRef.current('store_reset'), {
message: notice.lost ? tStorageRef.current('evicted_lost', { count: notice.lost }) : undefined,
duration: 0,
action: openStorage,
});
break;
case 'store-unavailable':
toast.warning(tConflictsRef.current('store_unavailable'), { duration: 0 });
@@ -114,7 +130,7 @@ export function OfflineHost() {
return () => {
removeGate();
stop();
setOfflineNotifier(() => {});
setOfflineNotifier(null);
};
}, []);
+50 -59
View File
@@ -1,45 +1,55 @@
'use client';
import { useState } from 'react';
import { useFormatter, useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import { useTranslations } from 'next-intl';
import { AlertTriangle } from '@/components/icons';
import { SettingItem, SettingsSection, ToggleSwitch } from '@/components/settings/settings-section';
import { OrgLockedNote } from '@/components/settings/org-locked-note';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { SettingsSection } from '@/components/settings/settings-section';
import { Button } from '@/components/ui/button';
import { IS_LITE } from '@/lib/lite';
import { formatFileSize } from '@/lib/utils';
import { disableOffline, enableOffline, offlineAllowed, offlineSupported } from '@/lib/offline/offline-mode';
import { isDesktopApp } from '@/lib/desktop/bridge';
import { signedInAccountIds } from '@/lib/device-policy';
import { offlineSupported, readPersistence, refreshUsage, requestPersistence, safariMayDelete } from '@/lib/offline/offline-mode';
import { useOfflineStore } from '@/lib/offline/state';
import { useDevicePolicy } from '@/hooks/use-device-policy';
import { useAccountStore } from '@/stores/account-store';
import { useAuthStore } from '@/stores/auth-store';
import { AccountStorage } from './account-storage';
import { OutboxList } from './outbox-list';
/**
* Settings > Backup & reset > Offline (#523): "Keep mail on this device"
* for the active account, the space it uses, and the outbox. Not in Lite.
* When the organisation forbids it, the switch is gone and a note says so.
* Settings > Backup & reset > Offline (#523): for each account signed in here,
* whether it keeps mail on this device, how much, what that takes, and the way
* to clear it. Above that what the browser may do to it, below it the outbox
* and whatever didn't go through. Not in Lite.
*/
export function OfflineSettings() {
const t = useTranslations('offline.settings');
const format = useFormatter();
const account = useAuthStore((s) => s.activeAccountId);
const tStorage = useTranslations('offlineStorage');
const active = useAuthStore((s) => s.activeAccountId);
useAuthStore((s) => s.connectedAccountsRevision);
// Re-renders when the organisation's device settings change.
const devices = useDevicePolicy();
const enabled = useOfflineStore((s) => !!account && s.enabled.includes(account));
const usage = useOfflineStore((s) => s.usageBytes);
const syncing = useOfflineStore((s) => !!account && s.syncing.includes(account));
const lastSynced = useOfflineStore((s) => (account ? s.lastSyncedAt[account] : undefined));
const unsent = useOfflineStore((s) => [...s.outbox, ...s.localDrafts].filter((row) => row.account === account).length);
useDevicePolicy();
const labels = useAccountStore((s) => s.accounts);
const enabledCount = useOfflineStore((s) => s.enabled.length);
const persist = useOfflineStore((s) => s.persist);
const blocked = useOfflineStore((s) => s.storageBlocked);
const tNotes = useTranslations('offlineConflicts');
const [confirmOff, setConfirmOff] = useState(false);
const [asking, setAsking] = useState(false);
useEffect(() => {
if (IS_LITE || enabledCount === 0) return;
void readPersistence();
void refreshUsage();
}, [enabledCount]);
if (IS_LITE) return null;
const allowed = !devices.noOffline && offlineAllowed(account);
const turnOff = () => {
if (!account) return;
void disableOffline(account);
const accounts = [...new Set([...(active ? [active] : []), ...signedInAccountIds()])];
const several = accounts.length > 1;
const askAgain = async () => {
setAsking(true);
await requestPersistence();
setAsking(false);
};
return (
@@ -48,51 +58,32 @@ export function OfflineSettings() {
<p className="text-sm text-muted-foreground" data-testid="offline-private">{tNotes('store_private')}</p>
) : !offlineSupported() ? (
<p className="text-sm text-muted-foreground" data-testid="offline-unsupported">{t('unsupported')}</p>
) : !allowed ? (
<div className="py-3" data-testid="offline-forbidden">
<p className="text-sm font-medium text-foreground">{t('keep_label')}</p>
<p className="mt-1 text-xs text-muted-foreground">{t('forbidden')}</p>
<OrgLockedNote className="mt-1" />
</div>
) : (
<>
<SettingItem label={t('keep_label')} description={t('keep_description')}>
<ToggleSwitch
checked={enabled}
testId="offline-switch"
onChange={(on) => {
if (!account) return;
if (on) void enableOffline(account);
else if (unsent > 0) setConfirmOff(true);
else turnOff();
}}
/>
</SettingItem>
<div className="flex items-start gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-900 dark:text-amber-200">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden="true" />
<p>{t('warning')}</p>
<p data-testid="offline-warning">{isDesktopApp() ? tStorage('warning_desktop') : t('warning')}</p>
</div>
{enabled && (
<p className="text-xs text-muted-foreground" role="status" aria-live="polite" data-testid="offline-status">
{syncing
? t('syncing')
: lastSynced
? t('usage_updated', { size: formatFileSize(usage), time: format.relativeTime(lastSynced, Date.now()) })
: t('usage', { size: formatFileSize(usage) })}
</p>
{enabledCount > 0 && persist === 'refused' && (
<div className="flex flex-col gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-900 dark:text-amber-200 sm:flex-row sm:items-center sm:justify-between" role="status" data-testid="offline-persist-refused">
<p className="min-w-0">{tStorage('persist_refused')}</p>
<Button variant="outline" size="sm" className="shrink-0" disabled={asking} onClick={() => void askAgain()} data-testid="offline-persist-retry">{tStorage('persist_retry')}</Button>
</div>
)}
{enabledCount > 0 && safariMayDelete() && (
<p className="rounded-md border border-border bg-muted px-3 py-2 text-xs text-foreground" data-testid="offline-safari-hint">{tStorage('safari_hint')}</p>
)}
{accounts.map((account, index) => (
<AccountStorage
key={account}
account={account}
primary={index === 0}
heading={several ? labels.find((entry) => entry.id === account)?.email || labels.find((entry) => entry.id === account)?.label || account : null}
/>
))}
</>
)}
<OutboxList />
<ConfirmDialog
isOpen={confirmOff}
onClose={() => setConfirmOff(false)}
onConfirm={() => { setConfirmOff(false); turnOff(); }}
title={t('off_confirm_title')}
message={t('off_confirm_message', { count: unsent })}
confirmText={t('off_confirm')}
variant="destructive"
/>
</SettingsSection>
);
}
+26 -3
View File
@@ -6,9 +6,9 @@ import { usePathname, useRouter } from '@/i18n/navigation';
import { Button } from '@/components/ui/button';
import { ConfirmDialog } from '@/components/ui/confirm-dialog';
import { deleteOutboxEntry, retryOutboxEntry } from '@/lib/offline/flush';
import { refreshQueueState, scheduleFlushAll } from '@/lib/offline/offline-mode';
import { dismissAreaConflict, refreshQueueState, scheduleFlushAll } from '@/lib/offline/offline-mode';
import { dismissConflict, retryLocalDraft, type ConflictEntry, type LocalDraftRow } from '@/lib/offline/queue';
import { isOfflineNow, useOfflineStore, type OutboxRow } from '@/lib/offline/state';
import { isOfflineNow, useOfflineStore, type AreaConflict, type OutboxRow } from '@/lib/offline/state';
import { useAccountStore } from '@/stores/account-store';
/**
@@ -25,6 +25,7 @@ export function OutboxList({ heading = true, onOpenDraft }: { heading?: boolean;
const t = useTranslations('offline.outbox');
const tErrors = useTranslations('offline.errors');
const tConflicts = useTranslations('offlineConflicts');
const tStorage = useTranslations('offlineStorage');
const tDrafts = useTranslations('offlineDrafts');
const router = useRouter();
const pathname = usePathname();
@@ -33,6 +34,7 @@ export function OutboxList({ heading = true, onOpenDraft }: { heading?: boolean;
const rows = useOfflineStore((s) => s.outbox);
const changes = useOfflineStore((s) => s.pendingChanges);
const conflicts = useOfflineStore((s) => s.conflicts);
const areaConflicts = useOfflineStore((s) => s.areaConflicts);
const accounts = useAccountStore((s) => s.accounts);
const [deleting, setDeleting] = useState<OutboxRow | null>(null);
const several = new Set(rows.map((r) => r.account)).size > 1 || accounts.length > 1;
@@ -81,6 +83,12 @@ export function OutboxList({ heading = true, onOpenDraft }: { heading?: boolean;
return tConflicts(conflict.summary.op === 'destroy' ? 'changed_delete' : 'changed');
};
const areaConflictText = (conflict: AreaConflict) => {
if (conflict.reason === 'refused') return tConflicts('refused', { reason: conflict.detail ?? '' });
if (conflict.reason === 'gone') return tConflicts('gone');
return tConflicts(conflict.destroy ? 'changed_delete' : 'changed');
};
const dismiss = async (conflict: ConflictEntry) => {
await dismissConflict(conflict.account, conflict.id);
await refreshQueueState();
@@ -144,7 +152,7 @@ export function OutboxList({ heading = true, onOpenDraft }: { heading?: boolean;
{changes > 0 && (
<p className="mt-2 text-xs text-muted-foreground" data-testid="offline-pending-changes">{t('changes', { count: changes })}</p>
)}
{conflicts.length > 0 && (
{conflicts.length + areaConflicts.length > 0 && (
<div className="mt-3" data-testid="offline-conflicts">
<p className="text-xs text-muted-foreground">{tConflicts('description')}</p>
<ul className="mt-2 divide-y divide-border rounded-md border border-border" aria-label={tConflicts('title')}>
@@ -161,6 +169,21 @@ export function OutboxList({ heading = true, onOpenDraft }: { heading?: boolean;
</div>
</li>
))}
{areaConflicts.map((conflict) => (
<li key={`${conflict.account}:${conflict.area}:${conflict.id}`} className="flex flex-col gap-2 px-3 py-2.5 sm:flex-row sm:items-start sm:justify-between" data-testid="offline-conflict-row" data-area={conflict.area} data-reason={conflict.reason}>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium text-foreground">
{tStorage(conflict.area === 'calendar' ? 'conflict_calendar' : 'conflict_contacts')}: {conflict.title || t('no_subject')}
</p>
<p className="text-xs text-destructive [overflow-wrap:anywhere]">
{areaConflictText(conflict)} · {format.dateTime(conflict.createdAt, { dateStyle: 'short', timeStyle: 'short' })}
</p>
</div>
<div className="flex shrink-0 gap-2">
<Button variant="outline" size="sm" aria-label={tConflicts('dismiss_label', { subject: conflict.title || t('no_subject') })} onClick={() => void dismissAreaConflict(conflict)}>{tConflicts('dismiss')}</Button>
</div>
</li>
))}
</ul>
</div>
)}
+2
View File
@@ -4,6 +4,7 @@ import { useRef, useState, type DragEvent, type KeyboardEvent } from "react";
import { useTranslations } from "next-intl";
import { Mail, Calendar, BookUser, HardDrive, Settings, PenSquare, MailOpen, Folder, Search, X, type AppIcon } from "@/components/icons";
import { cn } from "@/lib/utils";
import { useRestoredTabTitles } from "@/hooks/use-restored-tab-titles";
import { useProTabStore, type ProTab, type ProTabKind, type ProPaneId } from "@/stores/pro-tab-store";
import {
PRO_TAB_DRAG_MIME,
@@ -63,6 +64,7 @@ export function ProTabBar({
}: ProTabBarProps) {
const tSidebar = useTranslations("sidebar");
const reorderTab = useProTabStore((s) => s.reorderTab);
useRestoredTabTitles(tabs);
const [dropIndicator, setDropIndicator] = useState<DropIndicator>(null);
// True while an email or folder payload hovers the strip (both drop "anywhere").
+4 -1
View File
@@ -3,6 +3,8 @@
import { SidebarAppsSettings } from './sidebar-apps-settings';
import { PluginsSettings } from './plugins-settings';
import { usePolicyStore } from '@/stores/policy-store';
import { useDesktopPolicy } from '@/lib/desktop/policy';
import { DevicePluginsOff } from './device-plugins-off';
/**
* The Add-ons page: sidebar apps and plugins, the two ways to add things to
@@ -11,10 +13,11 @@ import { usePolicyStore } from '@/stores/policy-store';
*/
export function AddonsSettings() {
const { isFeatureEnabled } = usePolicyStore();
const devicePolicy = useDesktopPolicy();
return (
<div className="space-y-8">
{isFeatureEnabled('sidebarAppsEnabled') && <SidebarAppsSettings />}
{isFeatureEnabled('pluginsEnabled') && <PluginsSettings />}
{isFeatureEnabled('pluginsEnabled') ? <PluginsSettings /> : devicePolicy.PluginsMode.value === 'off' && <DevicePluginsOff />}
</div>
);
}
@@ -0,0 +1,20 @@
"use client";
import { useTranslations } from 'next-intl';
import { SettingItem, SettingsSection } from './settings-section';
/**
* The plugins part of the Add-ons page when the device's OS policy turns plugins
* off: says so, where the list would be, instead of the section just not being there.
*/
export function DevicePluginsOff() {
const t = useTranslations('desktop_policy');
const tPlugins = useTranslations('plugins.settings');
return (
<SettingsSection title={tPlugins('title')}>
<SettingItem label={t('plugins_label')} description={t('plugins_off')} locked>
<span className="text-sm text-muted-foreground" data-testid="device-plugins-off">{t('off')}</span>
</SettingItem>
</SettingsSection>
);
}
+18 -3
View File
@@ -15,6 +15,8 @@ import { PluginPermissionList } from '@/components/plugins/plugin-permission-lis
import type { InstalledPlugin, PluginStatus, SettingFieldSchema } from '@/lib/plugin-types';
import { effectiveSettings } from '@/lib/plugin-settings';
import { PLUGIN_REFUSED_ERROR } from '@/lib/outgoing-hosts';
import { PLUGIN_POLICY_REFUSED_ERROR, pluginUploadsAllowedByDesktopPolicy, useDesktopPolicy } from '@/lib/desktop/policy';
import { OrgLockedNote } from './org-locked-note';
import { lookup } from '@/lib/plugin-sandbox/plugin-i18n';
import { forgetCredentials, listConnectedAccounts, type ConnectedAccount } from '@/lib/plugin-sandbox/connect-dialog';
import { forwardApprovals, revokeForwardApproval } from '@/lib/plugin-sandbox/forward-approvals';
@@ -35,6 +37,8 @@ export function PluginsSettings() {
const { plugins, installPlugin, uninstallPlugin, enablePlugin, disablePlugin, updatePluginSettings, initializePlugins, initialized } = usePluginStore();
const { isFeatureEnabled, isPluginForceEnabled, fetchPolicy, loaded } = usePolicyStore();
const { demoMode } = useConfig();
const tDevicePolicyPage = useTranslations('desktop_policy');
const devicePolicy = useDesktopPolicy();
const [expandedPlugin, setExpandedPlugin] = useState<string | null>(null);
const [installing, setInstalling] = useState(false);
const [installError, setInstallError] = useState<string | null>(null);
@@ -64,7 +68,7 @@ export function PluginsSettings() {
return null;
}
const uploadsAllowed = isFeatureEnabled('pluginsUploadEnabled') && !demoMode;
const uploadsAllowed = isFeatureEnabled('pluginsUploadEnabled') && !demoMode && pluginUploadsAllowedByDesktopPolicy(devicePolicy);
const requireApproval = isFeatureEnabled('requirePluginApproval');
const handleToggle = async (plugin: InstalledPlugin) => {
@@ -97,7 +101,8 @@ export function PluginsSettings() {
const result = await installPlugin(file);
if (!result.success) {
setInstallError(
result.reason === 'uploads-disabled' ? t('install_uploads_off')
result.reason === 'device-policy' ? tDevicePolicyPage('install_refused')
: result.reason === 'uploads-disabled' ? t('install_uploads_off')
: result.reason === 'managed-id' ? t('install_managed_id')
: t('install_invalid', { details: result.error ?? '' }),
);
@@ -118,6 +123,14 @@ export function PluginsSettings() {
title={t('title')}
description={demoMode ? t('description_demo') : uploadsAllowed ? t('description_uploads') : t('description')}
>
{devicePolicy.PluginsMode.locked && devicePolicy.PluginsMode.value !== 'allowed' && (
<div className="py-2" data-testid="plugins-device-policy">
<p className="text-sm text-muted-foreground">
{tDevicePolicyPage(devicePolicy.PluginsMode.value === 'off' ? 'plugins_off' : 'plugins_allowlist')}
</p>
<OrgLockedNote className="mt-1" />
</div>
)}
{uploadsAllowed && (
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
@@ -216,12 +229,14 @@ interface PluginCardProps {
function PluginCard({ plugin, isExpanded, isForceEnabled, controlsDisabled, onToggleExpand, onToggle, onRemove, onUpdateSettings }: PluginCardProps) {
const t = useTranslations('plugins.settings');
const tThirdParty = useTranslations('third_party');
const tDevicePolicy = useTranslations('desktop_policy');
const locale = useLocale();
const isManaged = Boolean(plugin.managed);
const awaiting = !isManaged && plugin.approvalStatus === 'pending';
const denied = !isManaged && plugin.approvalStatus === 'denied';
const errorText = plugin.error
? plugin.error === PLUGIN_REFUSED_ERROR ? tThirdParty('plugin_refused')
? plugin.error === PLUGIN_POLICY_REFUSED_ERROR ? tDevicePolicy('plugin_refused')
: plugin.error === PLUGIN_REFUSED_ERROR ? tThirdParty('plugin_refused')
: ERROR_CODES.has(plugin.error) ? t(`error_${plugin.error.replace(/-/g, '_')}`) : plugin.error
: null;
+220
View File
@@ -0,0 +1,220 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated from lib/desktop/policy-schema.ts by scripts/generate-desktop-policies.mjs. Do not edit. -->
<policyDefinitions xmlns="http://schemas.microsoft.com/GroupPolicy/2006/07/PolicyDefinitions" revision="1.0" schemaVersion="1.0">
<policyNamespaces>
<target prefix="bulwark" namespace="Bulwark.Policies.Desktop"/>
</policyNamespaces>
<resources minRequiredRevision="1.0"/>
<supportedOn>
<definitions>
<definition name="SUPPORTED_Bulwark_Desktop" displayName="$(string.SUPPORTED_Bulwark_Desktop)"/>
</definitions>
</supportedOn>
<categories>
<category name="Bulwark" displayName="$(string.Category_Bulwark)"/>
<category name="Bulwark_Desktop" displayName="$(string.Category_Desktop)"><parentCategory ref="Bulwark"/></category>
<category name="Bulwark_Desktop_Recommended" displayName="$(string.Category_Recommended)"><parentCategory ref="Bulwark"/></category>
<category name="Bulwark_Desktop_connection" displayName="$(string.Category_connection)"><parentCategory ref="Bulwark_Desktop"/></category>
<category name="Bulwark_Desktop_updates" displayName="$(string.Category_updates)"><parentCategory ref="Bulwark_Desktop"/></category>
<category name="Bulwark_Desktop_plugins" displayName="$(string.Category_plugins)"><parentCategory ref="Bulwark_Desktop"/></category>
<category name="Bulwark_Desktop_security" displayName="$(string.Category_security)"><parentCategory ref="Bulwark_Desktop"/></category>
<category name="Bulwark_Desktop_privacy" displayName="$(string.Category_privacy)"><parentCategory ref="Bulwark_Desktop"/></category>
<category name="Bulwark_Desktop_signIn" displayName="$(string.Category_signIn)"><parentCategory ref="Bulwark_Desktop"/></category>
</categories>
<policies>
<policy name="InstanceUrl" class="Both" displayName="$(string.InstanceUrl)" explainText="$(string.InstanceUrl_Explain)" presentation="$(presentation.InstanceUrl)" key="Software\Policies\Bulwark\Desktop">
<parentCategory ref="Bulwark_Desktop_connection"/>
<supportedOn ref="SUPPORTED_Bulwark_Desktop"/>
<elements>
<text id="InstanceUrl_Value" valueName="InstanceUrl" required="true" maxLength="2048"/>
</elements>
</policy>
<policy name="InstanceUrl_Recommended" class="Both" displayName="$(string.InstanceUrl)" explainText="$(string.InstanceUrl_Explain)" presentation="$(presentation.InstanceUrl_Recommended)" key="Software\Policies\Bulwark\Desktop\Recommended">
<parentCategory ref="Bulwark_Desktop_Recommended"/>
<supportedOn ref="SUPPORTED_Bulwark_Desktop"/>
<elements>
<text id="InstanceUrl_Recommended_Value" valueName="InstanceUrl" required="true" maxLength="2048"/>
</elements>
</policy>
<policy name="AllowStandalone" class="Both" displayName="$(string.AllowStandalone)" explainText="$(string.AllowStandalone_Explain)" key="Software\Policies\Bulwark\Desktop" valueName="AllowStandalone">
<parentCategory ref="Bulwark_Desktop_connection"/>
<supportedOn ref="SUPPORTED_Bulwark_Desktop"/>
<enabledValue><decimal value="1"/></enabledValue>
<disabledValue><decimal value="0"/></disabledValue>
</policy>
<policy name="AllowedJmapServers" class="Both" displayName="$(string.AllowedJmapServers)" explainText="$(string.AllowedJmapServers_Explain)" presentation="$(presentation.AllowedJmapServers)" key="Software\Policies\Bulwark\Desktop\AllowedJmapServers">
<parentCategory ref="Bulwark_Desktop_connection"/>
<supportedOn ref="SUPPORTED_Bulwark_Desktop"/>
<elements>
<list id="AllowedJmapServers_Value" valuePrefix=""/>
</elements>
</policy>
<policy name="AllowInsecureInstance" class="Both" displayName="$(string.AllowInsecureInstance)" explainText="$(string.AllowInsecureInstance_Explain)" key="Software\Policies\Bulwark\Desktop" valueName="AllowInsecureInstance">
<parentCategory ref="Bulwark_Desktop_connection"/>
<supportedOn ref="SUPPORTED_Bulwark_Desktop"/>
<enabledValue><decimal value="1"/></enabledValue>
<disabledValue><decimal value="0"/></disabledValue>
</policy>
<policy name="UpdateChannel" class="Both" displayName="$(string.UpdateChannel)" explainText="$(string.UpdateChannel_Explain)" presentation="$(presentation.UpdateChannel)" key="Software\Policies\Bulwark\Desktop">
<parentCategory ref="Bulwark_Desktop_updates"/>
<supportedOn ref="SUPPORTED_Bulwark_Desktop"/>
<elements>
<enum id="UpdateChannel_Value" valueName="UpdateChannel" required="true">
<item displayName="$(string.UpdateChannel_stable)"><value><string>stable</string></value></item>
<item displayName="$(string.UpdateChannel_beta)"><value><string>beta</string></value></item>
</enum>
</elements>
</policy>
<policy name="UpdateChannel_Recommended" class="Both" displayName="$(string.UpdateChannel)" explainText="$(string.UpdateChannel_Explain)" presentation="$(presentation.UpdateChannel_Recommended)" key="Software\Policies\Bulwark\Desktop\Recommended">
<parentCategory ref="Bulwark_Desktop_Recommended"/>
<supportedOn ref="SUPPORTED_Bulwark_Desktop"/>
<elements>
<enum id="UpdateChannel_Recommended_Value" valueName="UpdateChannel" required="true">
<item displayName="$(string.UpdateChannel_stable)"><value><string>stable</string></value></item>
<item displayName="$(string.UpdateChannel_beta)"><value><string>beta</string></value></item>
</enum>
</elements>
</policy>
<policy name="AutoUpdate" class="Both" displayName="$(string.AutoUpdate)" explainText="$(string.AutoUpdate_Explain)" key="Software\Policies\Bulwark\Desktop" valueName="AutoUpdate">
<parentCategory ref="Bulwark_Desktop_updates"/>
<supportedOn ref="SUPPORTED_Bulwark_Desktop"/>
<enabledValue><decimal value="1"/></enabledValue>
<disabledValue><decimal value="0"/></disabledValue>
</policy>
<policy name="AutoUpdate_Recommended" class="Both" displayName="$(string.AutoUpdate)" explainText="$(string.AutoUpdate_Explain)" key="Software\Policies\Bulwark\Desktop\Recommended" valueName="AutoUpdate">
<parentCategory ref="Bulwark_Desktop_Recommended"/>
<supportedOn ref="SUPPORTED_Bulwark_Desktop"/>
<enabledValue><decimal value="1"/></enabledValue>
<disabledValue><decimal value="0"/></disabledValue>
</policy>
<policy name="PluginsMode" class="Both" displayName="$(string.PluginsMode)" explainText="$(string.PluginsMode_Explain)" presentation="$(presentation.PluginsMode)" key="Software\Policies\Bulwark\Desktop">
<parentCategory ref="Bulwark_Desktop_plugins"/>
<supportedOn ref="SUPPORTED_Bulwark_Desktop"/>
<elements>
<enum id="PluginsMode_Value" valueName="PluginsMode" required="true">
<item displayName="$(string.PluginsMode_allowed)"><value><string>allowed</string></value></item>
<item displayName="$(string.PluginsMode_allowlist)"><value><string>allowlist</string></value></item>
<item displayName="$(string.PluginsMode_off)"><value><string>off</string></value></item>
</enum>
</elements>
</policy>
<policy name="PluginAllowlist" class="Both" displayName="$(string.PluginAllowlist)" explainText="$(string.PluginAllowlist_Explain)" presentation="$(presentation.PluginAllowlist)" key="Software\Policies\Bulwark\Desktop\PluginAllowlist">
<parentCategory ref="Bulwark_Desktop_plugins"/>
<supportedOn ref="SUPPORTED_Bulwark_Desktop"/>
<elements>
<list id="PluginAllowlist_Value" valuePrefix=""/>
</elements>
</policy>
<policy name="OfflineEnabled" class="Both" displayName="$(string.OfflineEnabled)" explainText="$(string.OfflineEnabled_Explain)" key="Software\Policies\Bulwark\Desktop" valueName="OfflineEnabled">
<parentCategory ref="Bulwark_Desktop_security"/>
<supportedOn ref="SUPPORTED_Bulwark_Desktop"/>
<enabledValue><decimal value="1"/></enabledValue>
<disabledValue><decimal value="0"/></disabledValue>
</policy>
<policy name="OfflineEnabled_Recommended" class="Both" displayName="$(string.OfflineEnabled)" explainText="$(string.OfflineEnabled_Explain)" key="Software\Policies\Bulwark\Desktop\Recommended" valueName="OfflineEnabled">
<parentCategory ref="Bulwark_Desktop_Recommended"/>
<supportedOn ref="SUPPORTED_Bulwark_Desktop"/>
<enabledValue><decimal value="1"/></enabledValue>
<disabledValue><decimal value="0"/></disabledValue>
</policy>
<policy name="AppLockRequired" class="Both" displayName="$(string.AppLockRequired)" explainText="$(string.AppLockRequired_Explain)" key="Software\Policies\Bulwark\Desktop" valueName="AppLockRequired">
<parentCategory ref="Bulwark_Desktop_security"/>
<supportedOn ref="SUPPORTED_Bulwark_Desktop"/>
<enabledValue><decimal value="1"/></enabledValue>
<disabledValue><decimal value="0"/></disabledValue>
</policy>
<policy name="AppLockRequired_Recommended" class="Both" displayName="$(string.AppLockRequired)" explainText="$(string.AppLockRequired_Explain)" key="Software\Policies\Bulwark\Desktop\Recommended" valueName="AppLockRequired">
<parentCategory ref="Bulwark_Desktop_Recommended"/>
<supportedOn ref="SUPPORTED_Bulwark_Desktop"/>
<enabledValue><decimal value="1"/></enabledValue>
<disabledValue><decimal value="0"/></disabledValue>
</policy>
<policy name="AppLockTimeoutMinutes" class="Both" displayName="$(string.AppLockTimeoutMinutes)" explainText="$(string.AppLockTimeoutMinutes_Explain)" presentation="$(presentation.AppLockTimeoutMinutes)" key="Software\Policies\Bulwark\Desktop">
<parentCategory ref="Bulwark_Desktop_security"/>
<supportedOn ref="SUPPORTED_Bulwark_Desktop"/>
<elements>
<decimal id="AppLockTimeoutMinutes_Value" valueName="AppLockTimeoutMinutes" required="true" minValue="1" maxValue="1440"/>
</elements>
</policy>
<policy name="AppLockTimeoutMinutes_Recommended" class="Both" displayName="$(string.AppLockTimeoutMinutes)" explainText="$(string.AppLockTimeoutMinutes_Explain)" presentation="$(presentation.AppLockTimeoutMinutes_Recommended)" key="Software\Policies\Bulwark\Desktop\Recommended">
<parentCategory ref="Bulwark_Desktop_Recommended"/>
<supportedOn ref="SUPPORTED_Bulwark_Desktop"/>
<elements>
<decimal id="AppLockTimeoutMinutes_Recommended_Value" valueName="AppLockTimeoutMinutes" required="true" minValue="1" maxValue="1440"/>
</elements>
</policy>
<policy name="TelemetryAllowed" class="Both" displayName="$(string.TelemetryAllowed)" explainText="$(string.TelemetryAllowed_Explain)" key="Software\Policies\Bulwark\Desktop" valueName="TelemetryAllowed">
<parentCategory ref="Bulwark_Desktop_privacy"/>
<supportedOn ref="SUPPORTED_Bulwark_Desktop"/>
<enabledValue><decimal value="1"/></enabledValue>
<disabledValue><decimal value="0"/></disabledValue>
</policy>
<policy name="TelemetryAllowed_Recommended" class="Both" displayName="$(string.TelemetryAllowed)" explainText="$(string.TelemetryAllowed_Explain)" key="Software\Policies\Bulwark\Desktop\Recommended" valueName="TelemetryAllowed">
<parentCategory ref="Bulwark_Desktop_Recommended"/>
<supportedOn ref="SUPPORTED_Bulwark_Desktop"/>
<enabledValue><decimal value="1"/></enabledValue>
<disabledValue><decimal value="0"/></disabledValue>
</policy>
<policy name="CrashReportsAllowed" class="Both" displayName="$(string.CrashReportsAllowed)" explainText="$(string.CrashReportsAllowed_Explain)" key="Software\Policies\Bulwark\Desktop" valueName="CrashReportsAllowed">
<parentCategory ref="Bulwark_Desktop_privacy"/>
<supportedOn ref="SUPPORTED_Bulwark_Desktop"/>
<enabledValue><decimal value="1"/></enabledValue>
<disabledValue><decimal value="0"/></disabledValue>
</policy>
<policy name="CrashReportsAllowed_Recommended" class="Both" displayName="$(string.CrashReportsAllowed)" explainText="$(string.CrashReportsAllowed_Explain)" key="Software\Policies\Bulwark\Desktop\Recommended" valueName="CrashReportsAllowed">
<parentCategory ref="Bulwark_Desktop_Recommended"/>
<supportedOn ref="SUPPORTED_Bulwark_Desktop"/>
<enabledValue><decimal value="1"/></enabledValue>
<disabledValue><decimal value="0"/></disabledValue>
</policy>
<policy name="QuickComposeShortcut" class="Both" displayName="$(string.QuickComposeShortcut)" explainText="$(string.QuickComposeShortcut_Explain)" presentation="$(presentation.QuickComposeShortcut)" key="Software\Policies\Bulwark\Desktop">
<parentCategory ref="Bulwark_Desktop_security"/>
<supportedOn ref="SUPPORTED_Bulwark_Desktop"/>
<elements>
<text id="QuickComposeShortcut_Value" valueName="QuickComposeShortcut" required="true" maxLength="64"/>
</elements>
</policy>
<policy name="QuickComposeShortcut_Recommended" class="Both" displayName="$(string.QuickComposeShortcut)" explainText="$(string.QuickComposeShortcut_Explain)" presentation="$(presentation.QuickComposeShortcut_Recommended)" key="Software\Policies\Bulwark\Desktop\Recommended">
<parentCategory ref="Bulwark_Desktop_Recommended"/>
<supportedOn ref="SUPPORTED_Bulwark_Desktop"/>
<elements>
<text id="QuickComposeShortcut_Recommended_Value" valueName="QuickComposeShortcut" required="true" maxLength="64"/>
</elements>
</policy>
<policy name="AuthServerAllowlist" class="Both" displayName="$(string.AuthServerAllowlist)" explainText="$(string.AuthServerAllowlist_Explain)" presentation="$(presentation.AuthServerAllowlist)" key="Software\Policies\Bulwark\Desktop">
<parentCategory ref="Bulwark_Desktop_signIn"/>
<supportedOn ref="SUPPORTED_Bulwark_Desktop"/>
<elements>
<text id="AuthServerAllowlist_Value" valueName="AuthServerAllowlist" required="true" maxLength="2048"/>
</elements>
</policy>
<policy name="AuthNegotiateDelegateAllowlist" class="Both" displayName="$(string.AuthNegotiateDelegateAllowlist)" explainText="$(string.AuthNegotiateDelegateAllowlist_Explain)" presentation="$(presentation.AuthNegotiateDelegateAllowlist)" key="Software\Policies\Bulwark\Desktop">
<parentCategory ref="Bulwark_Desktop_signIn"/>
<supportedOn ref="SUPPORTED_Bulwark_Desktop"/>
<elements>
<text id="AuthNegotiateDelegateAllowlist_Value" valueName="AuthNegotiateDelegateAllowlist" required="true" maxLength="2048"/>
</elements>
</policy>
<policy name="AutoSelectCertificateForUrls" class="Both" displayName="$(string.AutoSelectCertificateForUrls)" explainText="$(string.AutoSelectCertificateForUrls_Explain)" presentation="$(presentation.AutoSelectCertificateForUrls)" key="Software\Policies\Bulwark\Desktop\AutoSelectCertificateForUrls">
<parentCategory ref="Bulwark_Desktop_signIn"/>
<supportedOn ref="SUPPORTED_Bulwark_Desktop"/>
<elements>
<list id="AutoSelectCertificateForUrls_Value" valuePrefix=""/>
</elements>
</policy>
<policy name="KeystoreAllowedOrigins" class="Both" displayName="$(string.KeystoreAllowedOrigins)" explainText="$(string.KeystoreAllowedOrigins_Explain)" presentation="$(presentation.KeystoreAllowedOrigins)" key="Software\Policies\Bulwark\Desktop\KeystoreAllowedOrigins">
<parentCategory ref="Bulwark_Desktop_signIn"/>
<supportedOn ref="SUPPORTED_Bulwark_Desktop"/>
<elements>
<list id="KeystoreAllowedOrigins_Value" valuePrefix=""/>
</elements>
</policy>
<policy name="WipeOfflineData" class="Both" displayName="$(string.WipeOfflineData)" explainText="$(string.WipeOfflineData_Explain)" presentation="$(presentation.WipeOfflineData)" key="Software\Policies\Bulwark\Desktop">
<parentCategory ref="Bulwark_Desktop_security"/>
<supportedOn ref="SUPPORTED_Bulwark_Desktop"/>
<elements>
<text id="WipeOfflineData_Value" valueName="WipeOfflineData" required="true" maxLength="128"/>
</elements>
</policy>
</policies>
</policyDefinitions>
+15
View File
@@ -0,0 +1,15 @@
Generated by scripts/generate-desktop-policies.mjs. Do not edit these files.
Windows: copy Bulwark.admx to %SystemRoot%\PolicyDefinitions, and the en-US and
de-DE folders next to it (or to the central store in SYSVOL). In Intune, import
Bulwark.admx and en-US\Bulwark.adml.
macOS: org.bulwarkmail.desktop.mobileconfig is a sample. Change it and deploy it by MDM.
Linux: policies.json is a sample for /etc/bulwark/policies.json. It must be owned by root
and writable only by root (mode 644). Otherwise the app refuses to start.
Limits: these settings stop people from changing them inside the app. The app is
not signed yet, so someone who can write to its folder or run their own copy can
patch it. For a hard guarantee, also limit what may run with AppLocker or WDAC,
and install per machine with the MSI so standard users cannot write to the folder.
+81
View File
@@ -0,0 +1,81 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated from lib/desktop/policy-schema.ts by scripts/generate-desktop-policies.mjs. Do not edit. -->
<policyDefinitionResources xmlns="http://schemas.microsoft.com/GroupPolicy/2006/07/PolicyDefinitions" revision="1.0" schemaVersion="1.0">
<displayName>Bulwark Desktop</displayName>
<description>Einstellungen für die Bulwark-Desktop-App.</description>
<resources>
<stringTable>
<string id="Category_Bulwark">Bulwark</string>
<string id="Category_Desktop">Bulwark Desktop</string>
<string id="Category_Recommended">Bulwark Desktop: Standardwerte, die Benutzer ändern können</string>
<string id="SUPPORTED_Bulwark_Desktop">Alle Versionen von Bulwark Desktop</string>
<string id="Category_connection">Verbindung</string>
<string id="Category_updates">Updates</string>
<string id="Category_plugins">Plugins</string>
<string id="Category_security">Offline-E-Mails und App-Sperre</string>
<string id="Category_privacy">Datenschutz</string>
<string id="Category_signIn">Anmeldung und Zertifikate</string>
<string id="InstanceUrl">Bulwark-Server</string>
<string id="InstanceUrl_Explain">Die Adresse Ihres Bulwark-Servers, beginnend mit https://. Wenn erzwungen, öffnet sich die App auf diesem Server, die Serverauswahl ist ausgeblendet und das Fenster bleibt auf diesem Server. Eine ungültige Adresse zeigt eine Fehlerseite mit dem Namen dieser Richtlinie, und die App weicht auf nichts anderes aus. Als Standardwert öffnet sich die App auf diesem Server, bis der Benutzer etwas anderes wählt.</string>
<string id="AllowStandalone">Direkte Verbindungen zu Mailservern zulassen</string>
<string id="AllowStandalone_Explain">Wenn deaktiviert, öffnet die App nur einen Bulwark-Server, und niemand kann sich direkt bei einem Mailserver anmelden. Früher hinzugefügte Konten bleiben auf dem Datenträger, sind aber ausgeblendet. Wenn Sie einen Bulwark-Server erzwingen und diese Richtlinie nicht konfigurieren, sind direkte Verbindungen ebenfalls aus. Aktivieren Sie sie, um beides anzubieten.</string>
<string id="AllowedJmapServers">Zugelassene Mailserver</string>
<string id="AllowedJmapServers_Explain">Die Mailserver, bei denen sich Benutzer anmelden dürfen. Einer pro Zeile: eine Adresse wie https://mail.example.com oder https://*.example.com für alle Subdomains. Ein Server ohne Schema bedeutet https. Unverschlüsseltes http muss mit http:// angegeben werden. Ist diese Liste gesetzt, wird alles andere abgelehnt, und Konten auf einem Server, der von der Liste genommen wird, ruhen, bis er wieder darauf steht, und behalten ihre Daten. Ein Bulwark-Server, den der Benutzer wählt, oder der unter „Bulwark-Server“ als Standardwert muss ebenfalls auf der Liste stehen. Ein erzwungener nicht.</string>
<string id="AllowInsecureInstance">Bulwark-Server über unverschlüsseltes http zulassen</string>
<string id="AllowInsecureInstance_Explain">Wenn aktiviert, darf die Adresse unter „Bulwark-Server“ mit http:// beginnen. Passwörter und E-Mails gehen dann unverschlüsselt über das Netzwerk, und Funktionen, die https brauchen, wie Offline-E-Mails und S/MIME, funktionieren nicht. Lassen Sie dies aus, sofern der Server nicht in einem Netzwerk steht, dem Sie vertrauen.</string>
<string id="UpdateChannel">Update-Kanal</string>
<string id="UpdateChannel_Explain">Auf welche Versionen die App aktualisiert wird.</string>
<string id="UpdateChannel_stable">Stabil</string>
<string id="UpdateChannel_beta">Beta</string>
<string id="AutoUpdate">Automatisch aktualisieren</string>
<string id="AutoUpdate_Explain">Wenn deaktiviert, sucht die App nicht nach Updates und lädt keine herunter. Verwenden Sie dies, wenn SCCM, Intune oder ähnliche Werkzeuge die Updates liefern.</string>
<string id="PluginsMode">Welche Plugins laufen dürfen</string>
<string id="PluginsMode_Explain">Ob Plugins installiert und ausgeführt werden dürfen. Mit der Liste oder ohne Plugins können Benutzer selbst keine Plugins hinzufügen, weil ein Paket jede beliebige ID nennen kann. Mit der Liste laufen nur Plugins, die Ihr Bulwark-Server oder das Verzeichnis der App bereitstellt. Ein abgelehntes Plugin behält seine Daten und Einstellungen: Es kommt unverändert zurück, wenn die Richtlinie es wieder erlaubt.</string>
<string id="PluginsMode_allowed">Alle Plugins sind erlaubt</string>
<string id="PluginsMode_allowlist">Nur Plugins auf der Liste</string>
<string id="PluginsMode_off">Keine Plugins</string>
<string id="PluginAllowlist">Erlaubte Plugins</string>
<string id="PluginAllowlist_Explain">Die IDs der Plugins, die laufen dürfen, wenn „Welche Plugins laufen dürfen“ auf die Liste gesetzt ist. Eine pro Zeile.</string>
<string id="OfflineEnabled">E-Mails auf dem Gerät behalten</string>
<string id="OfflineEnabled_Explain">Ob Konten eine verschlüsselte Kopie der E-Mails auf diesem Gerät für die Offline-Nutzung behalten. Wenn erzwungen aus, werden bereits vorhandene Kopien entfernt.</string>
<string id="AppLockRequired">App-Sperre verlangen</string>
<string id="AppLockRequired_Explain">Wenn aktiviert, müssen Benutzer die App-Sperre einrichten und können sie nicht ausschalten.</string>
<string id="AppLockTimeoutMinutes">App sperren nach</string>
<string id="AppLockTimeoutMinutes_Explain">Wie viele Minuten ohne Nutzung die App entsperrt bleibt. Von 1 bis 1440.</string>
<string id="TelemetryAllowed">Nutzungsstatistiken zulassen</string>
<string id="TelemetryAllowed_Explain">Wenn deaktiviert, sendet die App keine Nutzungsstatistiken und fragt nicht danach. Wenn aktiviert oder nicht konfiguriert, fragt sie den Benutzer trotzdem zuerst.</string>
<string id="CrashReportsAllowed">Absturzberichte zulassen</string>
<string id="CrashReportsAllowed_Explain">Wenn deaktiviert, sendet die App keine Absturzberichte und fragt nicht danach. Wenn aktiviert oder nicht konfiguriert, fragt sie den Benutzer trotzdem zuerst.</string>
<string id="QuickComposeShortcut">Globale Tastenkombination für neue Nachricht</string>
<string id="QuickComposeShortcut_Explain">Die Tastenkombination, die von überall aus eine neue Nachricht öffnet, etwa CommandOrControl+Alt+N. Geben Sie off ein, um sie auszuschalten.</string>
<string id="AuthServerAllowlist">Server für die integrierte Anmeldung</string>
<string id="AuthServerAllowlist_Explain">Die Server, die die Windows-Anmeldung (Kerberos, NTLM) verwenden dürfen, durch Kommas getrennt, etwa *.example.com. Wird beim Neustart der App wirksam.</string>
<string id="AuthNegotiateDelegateAllowlist">Server für die delegierte Anmeldung</string>
<string id="AuthNegotiateDelegateAllowlist_Explain">Die Server, an die die App Kerberos-Anmeldedaten delegieren darf, durch Kommas getrennt. Wird beim Neustart der App wirksam.</string>
<string id="AutoSelectCertificateForUrls">Clientzertifikate automatisch wählen</string>
<string id="AutoSelectCertificateForUrls_Explain">Eine JSON-Regel pro Zeile, im Format von Chrome: {&quot;pattern&quot;:&quot;https://[*.]example.com&quot;,&quot;filter&quot;:{&quot;ISSUER&quot;:{&quot;CN&quot;:&quot;Example CA&quot;}}}. Die App wählt dann für diese Website das passende Zertifikat ohne Rückfrage.</string>
<string id="KeystoreAllowedOrigins">Websites, die den Zertifikatspeicher des Systems verwenden dürfen</string>
<string id="KeystoreAllowedOrigins_Explain">Die Bulwark-Server, nach https-Adresse, deren Plugins mit den Zertifikaten im Speicher des Betriebssystems signieren und entschlüsseln dürfen, ohne dass der Benutzer für jede Website gefragt wird. Eine pro Zeile.</string>
<string id="WipeOfflineData">Auf dem Gerät behaltene E-Mails löschen</string>
<string id="WipeOfflineData_Explain">Setzen Sie dies auf einen neuen Wert, etwa das heutige Datum, um alles zu löschen, was die App für alle Server und Konten auf dem Gerät behält: E-Mails, Schlüssel und Anmeldungen, Benutzer melden sich also neu an. Jeder Wert löscht einmal: Die App merkt sich, welche sie schon ausgeführt hat, ein Wert kann also stehen bleiben. Verwenden Sie einen neuen Wert, um erneut zu löschen.</string>
</stringTable>
<presentationTable>
<presentation id="InstanceUrl"><textBox refId="InstanceUrl_Value"><label>Adresse des Bulwark-Servers</label></textBox></presentation>
<presentation id="InstanceUrl_Recommended"><textBox refId="InstanceUrl_Recommended_Value"><label>Adresse des Bulwark-Servers</label></textBox></presentation>
<presentation id="AllowedJmapServers"><listBox refId="AllowedJmapServers_Value">Zugelassene Mailserver</listBox></presentation>
<presentation id="UpdateChannel"><dropdownList refId="UpdateChannel_Value" noSort="true" defaultItem="0">Update-Kanal</dropdownList></presentation>
<presentation id="UpdateChannel_Recommended"><dropdownList refId="UpdateChannel_Recommended_Value" noSort="true" defaultItem="0">Update-Kanal</dropdownList></presentation>
<presentation id="PluginsMode"><dropdownList refId="PluginsMode_Value" noSort="true" defaultItem="0">Welche Plugins laufen dürfen</dropdownList></presentation>
<presentation id="PluginAllowlist"><listBox refId="PluginAllowlist_Value">Plugin-IDs</listBox></presentation>
<presentation id="AppLockTimeoutMinutes"><decimalTextBox refId="AppLockTimeoutMinutes_Value" defaultValue="10">Minuten</decimalTextBox></presentation>
<presentation id="AppLockTimeoutMinutes_Recommended"><decimalTextBox refId="AppLockTimeoutMinutes_Recommended_Value" defaultValue="10">Minuten</decimalTextBox></presentation>
<presentation id="QuickComposeShortcut"><textBox refId="QuickComposeShortcut_Value"><label>Tastenkombination</label></textBox></presentation>
<presentation id="QuickComposeShortcut_Recommended"><textBox refId="QuickComposeShortcut_Recommended_Value"><label>Tastenkombination</label></textBox></presentation>
<presentation id="AuthServerAllowlist"><textBox refId="AuthServerAllowlist_Value"><label>Server</label></textBox></presentation>
<presentation id="AuthNegotiateDelegateAllowlist"><textBox refId="AuthNegotiateDelegateAllowlist_Value"><label>Server</label></textBox></presentation>
<presentation id="AutoSelectCertificateForUrls"><listBox refId="AutoSelectCertificateForUrls_Value">Regeln</listBox></presentation>
<presentation id="KeystoreAllowedOrigins"><listBox refId="KeystoreAllowedOrigins_Value">Adressen</listBox></presentation>
<presentation id="WipeOfflineData"><textBox refId="WipeOfflineData_Value"><label>Löschcode</label></textBox></presentation>
</presentationTable>
</resources>
</policyDefinitionResources>
+81
View File
@@ -0,0 +1,81 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generated from lib/desktop/policy-schema.ts by scripts/generate-desktop-policies.mjs. Do not edit. -->
<policyDefinitionResources xmlns="http://schemas.microsoft.com/GroupPolicy/2006/07/PolicyDefinitions" revision="1.0" schemaVersion="1.0">
<displayName>Bulwark Desktop</displayName>
<description>Settings for the Bulwark desktop app.</description>
<resources>
<stringTable>
<string id="Category_Bulwark">Bulwark</string>
<string id="Category_Desktop">Bulwark Desktop</string>
<string id="Category_Recommended">Bulwark Desktop: defaults users can change</string>
<string id="SUPPORTED_Bulwark_Desktop">All versions of Bulwark Desktop</string>
<string id="Category_connection">Connection</string>
<string id="Category_updates">Updates</string>
<string id="Category_plugins">Plugins</string>
<string id="Category_security">Offline mail and app lock</string>
<string id="Category_privacy">Privacy</string>
<string id="Category_signIn">Sign-in and certificates</string>
<string id="InstanceUrl">Bulwark server</string>
<string id="InstanceUrl_Explain">The address of your Bulwark server, starting with https://. When forced, the app opens on this server, the choice of server is hidden, and the window stays on it. An invalid address shows an error page that names this policy, and the app does not fall back to anything else. As a default, the app opens on this server until the user picks something else.</string>
<string id="AllowStandalone">Allow direct connections to mail servers</string>
<string id="AllowStandalone_Explain">When disabled, the app only opens a Bulwark server and nobody can sign in directly to a mail server. Accounts added earlier stay on the disk, but are hidden. If you force a Bulwark server and leave this not configured, direct connections are off too. Enable it to offer both.</string>
<string id="AllowedJmapServers">Allowed mail servers</string>
<string id="AllowedJmapServers_Explain">The mail servers people may sign in to. One per line: an address such as https://mail.example.com, or https://*.example.com for all subdomains. A server without a scheme means https. Plain http must be named with http://. When this list is set, everything else is refused, and accounts on a server taken off the list stop until it is back, keeping their data. A Bulwark server the user picks, or the one under &quot;Bulwark server&quot; as a default, must be on the list too. A forced one does not need to be.</string>
<string id="AllowInsecureInstance">Allow a Bulwark server on plain http</string>
<string id="AllowInsecureInstance_Explain">When enabled, the address in &quot;Bulwark server&quot; may start with http://. Passwords and mail then travel unencrypted, and the browser features that need https, such as offline mail and S/MIME, do not work. Leave this off unless the server is on a network you trust.</string>
<string id="UpdateChannel">Update channel</string>
<string id="UpdateChannel_Explain">Which releases the app updates to.</string>
<string id="UpdateChannel_stable">Stable</string>
<string id="UpdateChannel_beta">Beta</string>
<string id="AutoUpdate">Update automatically</string>
<string id="AutoUpdate_Explain">When disabled, the app does not look for updates or download them. Use this when SCCM, Intune or similar tools deliver updates.</string>
<string id="PluginsMode">Which plugins may run</string>
<string id="PluginsMode_Explain">Whether plugins can be installed and run. With the list or none, people cannot add a plugin themselves, since a package can name any id. With the list, only plugins your Bulwark server or the app&apos;s directory provides can run. A plugin that is refused keeps its data and settings: it comes back as it was if the policy allows it again.</string>
<string id="PluginsMode_allowed">All plugins are allowed</string>
<string id="PluginsMode_allowlist">Only plugins on the list</string>
<string id="PluginsMode_off">No plugins</string>
<string id="PluginAllowlist">Allowed plugins</string>
<string id="PluginAllowlist_Explain">The ids of the plugins that may run when &quot;Which plugins may run&quot; is set to the list. One per line.</string>
<string id="OfflineEnabled">Keep mail on the device</string>
<string id="OfflineEnabled_Explain">Whether accounts keep an encrypted copy of mail on this device for offline use. When forced off, copies already on the device are removed.</string>
<string id="AppLockRequired">Require an app lock</string>
<string id="AppLockRequired_Explain">When enabled, people must set up the app lock and cannot turn it off.</string>
<string id="AppLockTimeoutMinutes">Lock the app after</string>
<string id="AppLockTimeoutMinutes_Explain">How many minutes without use the app stays unlocked. From 1 to 1440.</string>
<string id="TelemetryAllowed">Allow usage statistics</string>
<string id="TelemetryAllowed_Explain">When disabled, the app sends no usage statistics and does not ask. When enabled or not configured, it still asks the user first.</string>
<string id="CrashReportsAllowed">Allow crash reports</string>
<string id="CrashReportsAllowed_Explain">When disabled, the app sends no crash reports and does not ask. When enabled or not configured, it still asks the user first.</string>
<string id="QuickComposeShortcut">Global shortcut for a new message</string>
<string id="QuickComposeShortcut_Explain">The key combination that opens a new message from anywhere, such as CommandOrControl+Alt+N. Enter off to turn the shortcut off.</string>
<string id="AuthServerAllowlist">Servers for integrated sign-in</string>
<string id="AuthServerAllowlist_Explain">The servers that may use the Windows sign-in (Kerberos, NTLM), separated by commas, such as *.example.com. Takes effect when the app restarts.</string>
<string id="AuthNegotiateDelegateAllowlist">Servers for delegated sign-in</string>
<string id="AuthNegotiateDelegateAllowlist_Explain">The servers the app may delegate Kerberos credentials to, separated by commas. Takes effect when the app restarts.</string>
<string id="AutoSelectCertificateForUrls">Choose client certificates automatically</string>
<string id="AutoSelectCertificateForUrls_Explain">One JSON rule per line, in the format Chrome uses: {&quot;pattern&quot;:&quot;https://[*.]example.com&quot;,&quot;filter&quot;:{&quot;ISSUER&quot;:{&quot;CN&quot;:&quot;Example CA&quot;}}}. The app then picks the matching certificate for that site without asking.</string>
<string id="KeystoreAllowedOrigins">Sites that may use the system certificate store</string>
<string id="KeystoreAllowedOrigins_Explain">The Bulwark servers, by https address, whose plugins may sign and decrypt with the certificates in the operating system&apos;s store, without the user being asked for each site. One per line.</string>
<string id="WipeOfflineData">Delete mail kept on the device</string>
<string id="WipeOfflineData_Explain">Set this to a new value, such as today&apos;s date, to delete everything the app keeps on the device for every server and account: mail, keys, and sign-ins, so people sign in again. Each value deletes once: the app remembers the ones it has acted on, so it stays safe to leave a value in place. Use a new value to delete again.</string>
</stringTable>
<presentationTable>
<presentation id="InstanceUrl"><textBox refId="InstanceUrl_Value"><label>Address of the Bulwark server</label></textBox></presentation>
<presentation id="InstanceUrl_Recommended"><textBox refId="InstanceUrl_Recommended_Value"><label>Address of the Bulwark server</label></textBox></presentation>
<presentation id="AllowedJmapServers"><listBox refId="AllowedJmapServers_Value">Allowed mail servers</listBox></presentation>
<presentation id="UpdateChannel"><dropdownList refId="UpdateChannel_Value" noSort="true" defaultItem="0">Update channel</dropdownList></presentation>
<presentation id="UpdateChannel_Recommended"><dropdownList refId="UpdateChannel_Recommended_Value" noSort="true" defaultItem="0">Update channel</dropdownList></presentation>
<presentation id="PluginsMode"><dropdownList refId="PluginsMode_Value" noSort="true" defaultItem="0">Which plugins may run</dropdownList></presentation>
<presentation id="PluginAllowlist"><listBox refId="PluginAllowlist_Value">Plugin ids</listBox></presentation>
<presentation id="AppLockTimeoutMinutes"><decimalTextBox refId="AppLockTimeoutMinutes_Value" defaultValue="10">Minutes</decimalTextBox></presentation>
<presentation id="AppLockTimeoutMinutes_Recommended"><decimalTextBox refId="AppLockTimeoutMinutes_Recommended_Value" defaultValue="10">Minutes</decimalTextBox></presentation>
<presentation id="QuickComposeShortcut"><textBox refId="QuickComposeShortcut_Value"><label>Shortcut</label></textBox></presentation>
<presentation id="QuickComposeShortcut_Recommended"><textBox refId="QuickComposeShortcut_Recommended_Value"><label>Shortcut</label></textBox></presentation>
<presentation id="AuthServerAllowlist"><textBox refId="AuthServerAllowlist_Value"><label>Servers</label></textBox></presentation>
<presentation id="AuthNegotiateDelegateAllowlist"><textBox refId="AuthNegotiateDelegateAllowlist_Value"><label>Servers</label></textBox></presentation>
<presentation id="AutoSelectCertificateForUrls"><listBox refId="AutoSelectCertificateForUrls_Value">Rules</listBox></presentation>
<presentation id="KeystoreAllowedOrigins"><listBox refId="KeystoreAllowedOrigins_Value">Addresses</listBox></presentation>
<presentation id="WipeOfflineData"><textBox refId="WipeOfflineData_Value"><label>Wipe code</label></textBox></presentation>
</presentationTable>
</resources>
</policyDefinitionResources>
@@ -0,0 +1,86 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated from lib/desktop/policy-schema.ts by scripts/generate-desktop-policies.mjs. Do not edit. -->
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>PayloadContent</key>
<array>
<dict>
<key>PayloadType</key>
<string>org.bulwarkmail.desktop</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>PayloadIdentifier</key>
<string>org.bulwarkmail.desktop.policy.settings</string>
<key>PayloadUUID</key>
<string>5E80E0D3-CC43-4BF3-85BB-594992C34B20</string>
<key>PayloadEnabled</key>
<true/>
<key>PayloadDisplayName</key>
<string>Bulwark settings</string>
<key>InstanceUrl</key>
<string>https://mail.example.com</string>
<key>AllowStandalone</key>
<false/>
<key>AllowedJmapServers</key>
<array>
<string>https://mail.example.com</string>
<string>https://*.mail.example.com</string>
</array>
<key>AllowInsecureInstance</key>
<false/>
<key>AutoUpdate</key>
<false/>
<key>PluginsMode</key>
<string>allowlist</string>
<key>PluginAllowlist</key>
<array>
<string>example-plugin</string>
</array>
<key>OfflineEnabled</key>
<true/>
<key>AppLockRequired</key>
<true/>
<key>AppLockTimeoutMinutes</key>
<integer>10</integer>
<key>TelemetryAllowed</key>
<false/>
<key>CrashReportsAllowed</key>
<false/>
<key>QuickComposeShortcut</key>
<string>CommandOrControl+Alt+N</string>
<key>AuthServerAllowlist</key>
<string>*.example.com</string>
<key>AuthNegotiateDelegateAllowlist</key>
<string>*.example.com</string>
<key>AutoSelectCertificateForUrls</key>
<array>
<string>{&quot;pattern&quot;:&quot;https://[*.]example.com&quot;,&quot;filter&quot;:{&quot;ISSUER&quot;:{&quot;CN&quot;:&quot;Example CA&quot;}}}</string>
</array>
<key>KeystoreAllowedOrigins</key>
<array>
<string>https://mail.example.com</string>
</array>
<key>Recommended</key>
<dict>
<key>UpdateChannel</key>
<string>stable</string>
</dict>
</dict>
</array>
<key>PayloadType</key>
<string>Configuration</string>
<key>PayloadVersion</key>
<integer>1</integer>
<key>PayloadIdentifier</key>
<string>org.bulwarkmail.desktop.policy</string>
<key>PayloadUUID</key>
<string>AEC7BA2B-DD06-4BDF-83E5-147573C181F4</string>
<key>PayloadDisplayName</key>
<string>Bulwark Desktop</string>
<key>PayloadDescription</key>
<string>A sample profile. Remove what you do not want to set, and change the rest.</string>
<key>PayloadScope</key>
<string>System</string>
</dict>
</plist>
+31
View File
@@ -0,0 +1,31 @@
{
"InstanceUrl": "https://mail.example.com",
"AllowStandalone": false,
"AllowedJmapServers": [
"https://mail.example.com",
"https://*.mail.example.com"
],
"AllowInsecureInstance": false,
"AutoUpdate": false,
"PluginsMode": "allowlist",
"PluginAllowlist": [
"example-plugin"
],
"OfflineEnabled": true,
"AppLockRequired": true,
"AppLockTimeoutMinutes": 10,
"TelemetryAllowed": false,
"CrashReportsAllowed": false,
"QuickComposeShortcut": "CommandOrControl+Alt+N",
"AuthServerAllowlist": "*.example.com",
"AuthNegotiateDelegateAllowlist": "*.example.com",
"AutoSelectCertificateForUrls": [
"{\"pattern\":\"https://[*.]example.com\",\"filter\":{\"ISSUER\":{\"CN\":\"Example CA\"}}}"
],
"KeystoreAllowedOrigins": [
"https://mail.example.com"
],
"Recommended": {
"UpdateChannel": "stable"
}
}
@@ -0,0 +1,63 @@
import 'fake-indexeddb/auto';
import { act, renderHook, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
import { useOfflineStore } from '@/lib/offline/state';
import { setStoreBackend, type StoreBackend } from '@/lib/store/client/api';
import { useAuthStore } from '@/stores/auth-store';
import { usePolicyStore } from '@/stores/policy-store';
import { useToastStore } from '@/stores/toast-store';
import { useKeepFileOffline } from '../use-keep-file-offline';
const FILE = { id: 'f1' };
const client = { getAccountId: () => 'j' } as never;
const area = vi.fn(async (_account: string, _name: string, _params?: unknown): Promise<unknown> => ({ files: [] }));
function setDevices(devices: Partial<typeof DEFAULT_ORGANISATION_POLICY.devices>) {
usePolicyStore.setState({
loaded: true, source: 'server', subject: { email: 'a@example.org', groups: [] },
policy: { ...usePolicyStore.getState().policy, organisation: { ...DEFAULT_ORGANISATION_POLICY, devices: { ...DEFAULT_ORGANISATION_POLICY.devices, ...devices } } },
} as never);
}
beforeEach(() => {
area.mockClear();
area.mockImplementation(async () => ({ files: [] }));
setStoreBackend({ supported: () => true, area } as unknown as StoreBackend);
useAuthStore.setState({ activeAccountId: 'acc' });
useOfflineStore.setState({ enabled: ['acc'], choices: { acc: { on: true } } });
useToastStore.setState({ toasts: [] } as never);
setDevices({});
});
afterEach(() => { setStoreBackend(null); });
describe('Keep offline in the Files app', () => {
it('is offered for a plain file, not for a folder', () => {
const { result } = renderHook(() => useKeepFileOffline(client));
expect(result.current.canKeepOffline(FILE)).toBe(true);
expect(result.current.canKeepOffline({ ...FILE, isDirectory: true })).toBe(false);
});
it('isn\'t offered when the organisation forbids keeping files', () => {
setDevices({ noOfflineAttachments: true });
const { result } = renderHook(() => useKeepFileOffline(client));
expect(result.current.canKeepOffline(FILE)).toBe(false);
});
it('isn\'t offered when the user keeps no attachments', () => {
useOfflineStore.setState({ choices: { acc: { on: true, attachments: false } } });
const { result } = renderHook(() => useKeepFileOffline(client));
expect(result.current.canKeepOffline(FILE)).toBe(false);
});
it('says so when the store has no row for the file yet', async () => {
area.mockImplementation(async (_account, name) => {
if (name === 'files.pin') throw new Error('No file f1');
return { files: [] };
});
const { result } = renderHook(() => useKeepFileOffline(client));
await act(async () => { await result.current.toggleKeptOffline(FILE); });
await waitFor(() => expect(useToastStore.getState().toasts.map((toast) => toast.title)).toContain('pin_not_ready'));
});
});
@@ -0,0 +1,46 @@
import { renderHook, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useAuthStore } from '@/stores/auth-store';
import { TITLE_UNTIL_LOADED, useProTabStore, type ProTab } from '@/stores/pro-tab-store';
import { useRestoredTabTitles } from '../use-restored-tab-titles';
const stored = (id: string, title = TITLE_UNTIL_LOADED): ProTab => ({
id, kind: 'email', labelKey: '', title, closeable: true, paneId: 'main',
emailData: { accountId: 'j', emailId: `m-${id}`, mailboxId: null, title },
});
const getEmail = vi.fn(async (id: string) => ({ subject: id === 'm-empty' ? '' : `Subject of ${id}` }));
function current(id: string) {
return useProTabStore.getState().tabs.find((tab) => tab.id === id);
}
beforeEach(() => {
getEmail.mockClear();
useAuthStore.setState({ client: { getEmail } as never });
});
describe('tabs restored without their subjects', () => {
it('get them back without being opened, also an empty one', async () => {
const tabs = [stored('a'), stored('empty'), stored('known', 'Known')];
useProTabStore.setState({ tabs });
renderHook(() => useRestoredTabTitles(tabs));
await waitFor(() => expect(current('a')?.title).toBe('Subject of m-a'));
expect(current('a')?.emailData?.title).toBe('Subject of m-a');
await waitFor(() => expect(current('empty')?.title).toBe('no_subject'));
expect(getEmail).toHaveBeenCalledTimes(2);
});
it('wait for a client, and keep their mark if the message can\'t be read', async () => {
const tabs = [stored('a')];
useProTabStore.setState({ tabs });
useAuthStore.setState({ client: null });
const { rerender } = renderHook(() => useRestoredTabTitles(tabs));
expect(getEmail).not.toHaveBeenCalled();
getEmail.mockRejectedValueOnce(new Error('offline'));
useAuthStore.setState({ client: { getEmail } as never });
rerender();
await waitFor(() => expect(getEmail).toHaveBeenCalled());
expect(current('a')?.title).toBe(TITLE_UNTIL_LOADED);
});
});
+58
View File
@@ -0,0 +1,58 @@
'use client';
import { useCallback, useMemo } from 'react';
import { useTranslations } from 'next-intl';
import { useDevicePolicy } from '@/hooks/use-device-policy';
import type { IJMAPClient } from '@/lib/jmap/client-interface';
import { scopeForAccount } from '@/lib/offline/offline-mode';
import { useOfflineStore } from '@/lib/offline/state';
import { toast } from '@/stores/toast-store';
import { useAuthStore } from '@/stores/auth-store';
import { useKeptFiles } from './use-kept-files';
interface FileRef {
id: string;
isDirectory?: boolean;
isSymlink?: boolean;
}
/**
* Nodes of another account are listed as "accountId:nodeId" (stores/file-store.ts).
* The store keeps them under the account they live in.
*/
export function fileRefFor(id: string, ownAccount: string): { jmap: string; id: string } {
const at = id.indexOf(':');
return at > 0 ? { jmap: id.slice(0, at), id: id.slice(at + 1) } : { jmap: ownAccount, id };
}
/** "Keep offline" in the Files app. Nothing to offer when the account doesn't keep mail here. */
export function useKeepFileOffline(client: IJMAPClient | null | undefined) {
const t = useTranslations('offlineStorage');
const account = useAuthStore((s) => s.activeAccountId);
const { active, pinned, pin, unpin } = useKeptFiles(account);
const own = client?.getAccountId?.() ?? '';
const keptOffline = useMemo(() => new Set(pinned.map((file) => (file.jmap === own ? file.id : `${file.jmap}:${file.id}`))), [pinned, own]);
// Nothing to offer where the organisation or the user keeps no files.
const devices = useDevicePolicy();
useOfflineStore((s) => (account ? s.choices[account] : undefined));
const attachmentsAllowed = !devices.noOfflineAttachments && !!account && scopeForAccount(account).scope.attachments;
const canKeepOffline = useCallback(
(resource: FileRef) => active && attachmentsAllowed && !!own && !resource.isDirectory && !resource.isSymlink,
[active, attachmentsAllowed, own],
);
const toggleKeptOffline = useCallback(async (resource: FileRef) => {
const ref = fileRefFor(resource.id, own);
if (keptOffline.has(resource.id)) {
await unpin(ref);
return;
}
// A file so new the store hasn't listed it yet has nothing to pin.
await pin(ref).catch(() => toast.info(t('pin_not_ready')));
}, [own, keptOffline, pin, unpin, t]);
return { keptOffline, canKeepOffline, toggleKeptOffline };
}
+59
View File
@@ -0,0 +1,59 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { getStore } from '@/lib/store/client/api';
import { offlineActive } from '@/lib/offline/offline-mode';
import { useOfflineStore } from '@/lib/offline/state';
/** A row of the Files area's `files.kept`. */
export interface KeptFile {
jmap: string;
id: string;
name: string;
size: number | null;
pinned: boolean;
kept: boolean;
error: string | null;
}
/**
* The files an account keeps offline, and pinning them. Nothing when the
* account doesn't keep mail here, or the build has no Files area.
*/
export function useKeptFiles(account: string | null | undefined) {
const [files, setFiles] = useState<KeptFile[]>([]);
const lastSynced = useOfflineStore((s) => (account ? s.lastSyncedAt[account] : undefined));
const active = useOfflineStore((s) => !!account && s.enabled.includes(account)) && offlineActive(account);
const read = useCallback(async () => {
if (!account || !active) {
setFiles([]);
return;
}
try {
setFiles((await getStore().area<{ files: KeptFile[] }>(account, 'files.kept'))?.files ?? []);
} catch {
// The store is starting or being deleted, or this build has no Files area.
setFiles([]);
}
}, [account, active]);
useEffect(() => { void read(); }, [read, lastSynced]);
const pinned = useMemo(() => files.filter((file) => file.pinned), [files]);
const pin = useCallback(async (ref: { jmap: string; id: string }) => {
if (!account) return;
await getStore().area(account, 'files.pin', ref);
await read();
}, [account, read]);
const unpin = useCallback(async (ref: { jmap: string; id: string }) => {
if (!account) return;
// The store being in trouble: the list says what's so.
await getStore().area(account, 'files.unpin', ref).catch(() => {});
await read();
}, [account, read]);
return { active, files, pinned, pin, unpin };
}
+33 -3
View File
@@ -13,9 +13,34 @@ export interface PluginRenderedBody {
text: string | null;
/** blob: URLs for the decrypted inline parts, keyed by Content-ID without brackets. */
cidUrls: Record<string, string>;
/** The plugin can't vouch for this body: external content stays off, whatever the settings allow. */
blockExternal: boolean;
}
const EMPTY: PluginRenderedBody = { html: null, text: null, cidUrls: {} };
const EMPTY: PluginRenderedBody = { html: null, text: null, cidUrls: {}, blockExternal: false };
/**
* What onRenderEmailBody gets to see, for its effect's dependencies. A render can be a
* key operation (a smart card's PIN dialog, and two cancels in a row lock the key), so
* it runs once per message, not per object: mark-read replaces the object and changes
* only keywords. The list row and then the full message do differ here: a row has no
* headers or body structure, which is what a plugin recognises its mail by. A plugin
* that wants to render again for another reason has api.ui.rerenderEmail.
*/
export function pluginRenderKey(email: Email | null | undefined): string | null {
if (!email) return null;
const contentType = email.headers?.['content-type'] ?? email.headers?.['Content-Type'] ?? '';
const bodyLengths = Object.values(email.bodyValues ?? {}).map((part) => part.value?.length ?? 0);
return JSON.stringify([
email.sourceClientAccountId ?? '',
email.id,
email.blobId ?? '',
contentType,
email.bodyStructure ? 1 : 0,
email.bodyValues ? bodyLengths : null,
email.attachments?.length ?? null,
]);
}
function dataUrlToBytes(dataUrl: string): Uint8Array | null {
try {
@@ -39,7 +64,8 @@ function dataUrlToBytes(dataUrl: string): Uint8Array | null {
* Asks the onRenderEmailBody hook for a replacement body, the way the single
* reader does, so a message a PGP or S/MIME plugin decrypts also shows
* decrypted inside a conversation (#902). Re-runs when a plugin calls
* api.ui.rerenderEmail, e.g. after the user unlocked a key.
* api.ui.rerenderEmail, e.g. after the user unlocked a key, and not when only the
* object changes (see pluginRenderKey).
*/
export function usePluginRenderedBody(email: Email): PluginRenderedBody {
const [body, setBody] = useState<PluginRenderedBody>(EMPTY);
@@ -79,6 +105,7 @@ export function usePluginRenderedBody(email: Email): PluginRenderedBody {
text?: string;
attachments?: Array<{ name?: string; type?: string; dataUrl?: string; cid?: string }>;
handledBy?: string;
blockExternalContent?: boolean;
};
if (cancelled) return;
if (!result || !result.handledBy) {
@@ -100,6 +127,7 @@ export function usePluginRenderedBody(email: Email): PluginRenderedBody {
html: typeof result.html === 'string' && result.html ? result.html : null,
text: typeof result.text === 'string' && result.text ? result.text : null,
cidUrls,
blockExternal: result.blockExternalContent === true,
});
} catch (err) {
if (cancelled) return;
@@ -112,7 +140,9 @@ export function usePluginRenderedBody(email: Email): PluginRenderedBody {
cancelled = true;
objectUrls.forEach((url) => URL.revokeObjectURL(url));
};
}, [email, nonce]);
// The latest message is read inside; see pluginRenderKey for what should run this again.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pluginRenderKey(email), nonce]);
return body;
}
+35
View File
@@ -0,0 +1,35 @@
'use client';
import { useEffect } from 'react';
import { useTranslations } from 'next-intl';
import { useAuthStore } from '@/stores/auth-store';
import { TITLE_UNTIL_LOADED, useProTabStore, type ProTab } from '@/stores/pro-tab-store';
/**
* With the local store on, stored tabs have no subjects. A tab's body puts its
* title back, but only an open tab has a body, so the others are asked for here.
*/
export function useRestoredTabTitles(tabs: readonly ProTab[]): void {
const t = useTranslations('email_composer');
const updateTabTitle = useProTabStore((s) => s.updateTabTitle);
const client = useAuthStore((s) => s.client);
const waiting = tabs.filter((tab) => tab.kind === 'email' && tab.title === TITLE_UNTIL_LOADED && tab.emailData);
const key = waiting.map((tab) => tab.id).join(' ');
useEffect(() => {
if (!key || !client) return;
let stale = false;
for (const tab of waiting) {
const data = tab.emailData!;
const owner = data.clientAccountId ? useAuthStore.getState().getClientForAccount(data.clientAccountId) : client;
// Its login is gone or not back yet. The next render asks again.
if (!owner) continue;
void owner.getEmail(data.emailId, data.accountId).then((email) => {
if (!stale && email) updateTabTitle(tab.id, email.subject || t('no_subject'));
}).catch(() => {});
}
return () => { stale = true; };
// `waiting` follows `key`.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key, client, updateTabTitle]);
}
+386
View File
@@ -938,6 +938,392 @@ test('at 390 px: the offline settings, the outbox and the reload prompt fit', as
await page.context().close();
});
// ---- What a device keeps (design A4, A7, A8, A9) ----
/** A message from long ago, in ent-alice's Inbox. Email/import takes the received date. */
async function importOld(account: string, subject: string, daysAgo: number): Promise<void> {
const client = await jmapAs(account);
const receivedAt = new Date(Date.now() - daysAgo * 86_400_000).toISOString().replace(/\.\d+Z$/, 'Z');
const raw = `From: ${ENT.bob}\r\nTo: ${account}\r\nSubject: ${subject}\r\nDate: ${new Date(receivedAt).toUTCString()}\r\nMessage-ID: <${subject.replace(/\W/g, '')}@example.org>\r\nContent-Type: text/plain\r\n\r\nOld body ${RUN}\r\n`;
const auth = 'Basic ' + Buffer.from(`${account}:${process.env.IT_ENT_PASSWORD}`).toString('base64');
const upload = await fetch(`${JMAP_URL}/jmap/upload/${client.accountId}/`, { method: 'POST', headers: { Authorization: auth, 'Content-Type': 'message/rfc822' }, body: raw });
if (!upload.ok) throw new Error(`upload: ${upload.status}`);
const { blobId } = await upload.json() as { blobId: string };
const boxes = await client.request([['Mailbox/query', { accountId: client.accountId, filter: { role: 'inbox' } }, '0']]);
const inbox = boxes.methodResponses[0][1].ids[0] as string;
const done = await client.request([['Email/import', { accountId: client.accountId, emails: { a: { blobId, mailboxIds: { [inbox]: true }, keywords: {}, receivedAt } } }, '0']]);
if (!done.methodResponses[0][1].created?.a) throw new Error(`import: ${JSON.stringify(done.methodResponses[0][1])}`);
}
/** How many messages the settings page says are kept. */
async function keptMessages(page: Page): Promise<number> {
const text = await page.getByTestId('offline-usage').locator('[data-part="mail"] dd').innerText();
return Number(text.match(/\(([\d.,]+) /)?.[1].replace(/[.,]/g, '') ?? NaN);
}
async function statusSettled(page: Page): Promise<void> {
await expect(page.getByTestId('offline-status')).toContainText('updated', { timeout: 180_000 });
}
test('a cap from the organisation prunes kept mail at the next policy fetch, and lifting it fetches the mail back', async ({ browser }) => {
const old = `Old message ${RUN}`;
const recent = `Recent message ${RUN}`;
await importOld(ENT.alice, old, 200);
await deliverAndWait(recent, `Recent body ${RUN}`);
const page = await userPage(browser, ENT.alice);
await enableOffline(page);
const windowRow = page.locator('[data-search-label="How far back to keep"]');
await windowRow.locator('select').selectOption('all');
await expect.poll(() => keptMessages(page), { timeout: 120_000 }).toBeGreaterThan(0);
await statusSettled(page);
await expect(async () => {
await page.getByTestId('offline-status').waitFor();
const client = await jmapAs(ENT.alice);
const total = await client.request([['Email/query', { accountId: client.accountId, calculateTotal: true, limit: 0 }, '0']]);
expect(await keptMessages(page)).toBe(total.methodResponses[0][1].total);
}).toPass({ timeout: 180_000, intervals: [2000] });
const before = await keptMessages(page);
await setPolicy(browser, { organisation: { devices: { offlineMaxDays: 30, noOfflineAttachments: true } } });
await page.reload();
await expect(windowRow.locator('select')).toHaveValue('30', { timeout: 60_000 });
await expect(windowRow.locator('select option')).toHaveText(['30 days']);
await expect(windowRow.getByText('Set by your organisation')).toBeVisible();
const attachments = page.getByTestId('offline-attachments');
await expect(attachments).toHaveAttribute('aria-checked', 'false');
await expect(page.locator('[data-search-label="Keep attachments"]').getByText('Set by your organisation')).toBeVisible();
await expect.poll(() => keptMessages(page), { timeout: 60_000 }).toBeLessThan(before);
await windowRow.scrollIntoViewIfNeeded();
await shot(page, 'do-storage-page-capped');
// Offline, the old message isn't there either: it left the device, not only the list.
await toMail(page);
await blockMailServer(page.context());
await waitUntilOffline(page);
const search = async (term: string) => {
const box = page.getByPlaceholder('Search mail').first();
await box.fill(term);
await box.press('Enter');
};
const rows = page.locator('[data-testid="email-list-item"]');
// A word of a message that stayed finds it, so the search does work here.
await search(recent);
await expect(rows.filter({ hasText: recent }).first()).toBeVisible({ timeout: 30_000 });
await search(old);
await expect(rows.filter({ hasText: recent })).toHaveCount(0, { timeout: 30_000 });
await expect(rows.filter({ hasText: old })).toHaveCount(0);
await page.context().unroute(`${new URL(JMAP_URL).origin}/**`);
await setPolicy(browser, {});
await page.goto('/settings/backup/offline');
await expect(windowRow.locator('select')).toHaveValue('all', { timeout: 60_000 });
await expect.poll(() => keptMessages(page), { timeout: 180_000, intervals: [2000] }).toBe(before);
await page.context().close();
});
test('the storage page: usage, choices, clearing an account', async ({ browser }) => {
const subject = `Cleared and fetched again ${RUN}`;
await deliverAndWait(subject, `Body ${RUN}`);
const page = await userPage(browser, ENT.alice);
await enableOffline(page);
await expect(page.getByTestId('offline-usage')).toContainText('Messages and search index');
await expect.poll(() => keptMessages(page), { timeout: 60_000 }).toBeGreaterThan(0);
await shot(page, 'do-storage-page');
// Keyboard: the choices are native controls in tab order, and the state survives a reload.
const select = page.locator('[data-search-label="How far back to keep"] select');
await select.focus();
await select.selectOption('365');
await page.reload();
await expect(select).toHaveValue('365', { timeout: 60_000 });
await expect(page.getByTestId('offline-status')).toContainText('updated', { timeout: 180_000 });
const before = await page.evaluate(async () => {
const db = await new Promise<IDBDatabase>((resolve, reject) => { const r = indexedDB.open('bulwark-store'); r.onsuccess = () => resolve(r.result); r.onerror = () => reject(r.error); });
const all = await new Promise<Array<{ file: string }>>((resolve) => { const r = db.transaction('accounts').objectStore('accounts').getAll(); r.onsuccess = () => resolve(r.result); });
db.close();
return all.map((record) => record.file);
});
expect(before).toHaveLength(1);
await page.getByTestId('offline-clear').click();
await expect(page.getByRole('alertdialog').or(page.getByRole('dialog'))).toContainText('Clear this account on this device?');
await page.getByRole('button', { name: 'Clear', exact: true }).click();
// A new key and file, and the mail comes back by itself, with the window kept.
await expect(async () => {
const after = await page.evaluate(async () => {
const db = await new Promise<IDBDatabase>((resolve, reject) => { const r = indexedDB.open('bulwark-store'); r.onsuccess = () => resolve(r.result); r.onerror = () => reject(r.error); });
const all = await new Promise<Array<{ file: string }>>((resolve) => { const r = db.transaction('accounts').objectStore('accounts').getAll(); r.onsuccess = () => resolve(r.result); });
db.close();
return all.map((record) => record.file);
});
expect(after).toHaveLength(1);
expect(after).not.toEqual(before);
}).toPass({ timeout: 60_000 });
await expect.poll(() => keptMessages(page), { timeout: 180_000, intervals: [2000] }).toBeGreaterThan(0);
await expect(select).toHaveValue('365');
await page.context().close();
});
test('the storage page fits at 390 px', async ({ browser }) => {
const phone = await userPage(browser, ENT.alice, true);
await enableOffline(phone, true);
// Scrolling happens in the pane, so the page's own width says nothing: nothing may reach past the screen.
const section = phone.locator('[data-settings-section="offline"]');
await expect(section).toBeVisible();
const past = await section.evaluate((root) => {
const width = document.documentElement.clientWidth;
const out: string[] = [];
for (const el of [root, ...root.querySelectorAll<HTMLElement>('*')]) {
const box = el.getBoundingClientRect();
if (box.width > 0 && (box.right > width + 1 || box.left < -1)) out.push(`${el.tagName} ${el.getAttribute('data-testid') ?? ''} ${Math.round(box.right)}`);
if (el.scrollWidth > el.clientWidth + 1 && getComputedStyle(el).overflowX !== 'visible' && getComputedStyle(el).overflowX !== 'hidden') out.push(`scrolls: ${el.tagName} ${el.className}`);
}
return out;
});
expect(past).toEqual([]);
await shot(phone, 'do-storage-page-390');
await phone.context().close();
});
test('a store the browser deleted is noticed, says what was lost and syncs again', async ({ browser }) => {
const subject = `After the eviction ${RUN}`;
await deliverAndWait(subject, `Body ${RUN}`);
const page = await userPage(browser, ENT.alice);
await enableOffline(page);
// No app on this page, so no worker holds the files, as after closing the tab.
await page.goto('/manifest.webmanifest');
await expect.poll(() => page.evaluate(async () => {
const root = await navigator.storage.getDirectory();
const raw = localStorage.getItem('bulwark-offline-health');
const health = JSON.parse(raw ?? '{}') as Record<string, { pending?: number }>;
for (const entry of Object.values(health)) entry.pending = 2;
localStorage.setItem('bulwark-offline-health', JSON.stringify(health));
return root.removeEntry('.bulwark-store', { recursive: true }).then(() => 'gone', (error: Error) => error.name);
}), { timeout: 30_000 }).toBe('gone');
await page.goto('/settings/backup/offline');
await expect(page.getByTestId('offline-evicted')).toContainText('The browser deleted the mail kept on this device', { timeout: 60_000 });
await expect(page.getByText("Offline mail on this device couldn't be read", { exact: false }).first()).toBeVisible();
await expect(page.getByTestId('offline-evicted')).toContainText('2 unsent messages or changes were lost');
await expect.poll(() => keptMessages(page), { timeout: 180_000, intervals: [2000] }).toBeGreaterThan(0);
await page.getByTestId('offline-evicted').getByRole('button', { name: 'OK' }).click();
await expect(page.getByTestId('offline-evicted')).toHaveCount(0);
// The key gone and the file still there: nobody can read it, so it goes too.
await page.goto('/manifest.webmanifest');
await page.evaluate(() => new Promise<void>((resolve, reject) => {
const r = indexedDB.open('bulwark-store');
r.onerror = () => reject(r.error);
r.onsuccess = () => {
const tx = r.result.transaction('accounts', 'readwrite');
tx.objectStore('accounts').clear();
tx.oncomplete = () => { r.result.close(); resolve(); };
};
}));
await page.goto('/settings/backup/offline');
await expect(page.getByTestId('offline-evicted')).toBeVisible({ timeout: 60_000 });
await expect.poll(() => keptMessages(page), { timeout: 180_000, intervals: [2000] }).toBeGreaterThan(0);
await page.context().close();
});
test('a browser that refuses to keep the data gets a warning and can be asked again', async ({ browser }) => {
const page = await userPage(browser, ENT.alice);
await page.context().addInitScript(() => {
const answer = () => Promise.resolve(Boolean((window as unknown as { __grant?: boolean }).__grant));
StorageManager.prototype.persist = answer;
StorageManager.prototype.persisted = answer;
});
await enableOffline(page);
await expect(page.getByTestId('offline-persist-refused')).toContainText('This browser may delete the kept mail');
await page.evaluate(() => { (window as unknown as { __grant?: boolean }).__grant = true; });
await page.getByTestId('offline-persist-retry').click();
await expect(page.getByTestId('offline-persist-refused')).toHaveCount(0);
await page.context().close();
});
/** A message with a text attachment of `size` bytes, repeating `term`, imported so the bytes stay as they are. */
async function importWithAttachment(account: string, subject: string, term: string, size: number): Promise<void> {
const client = await jmapAs(account);
const content = `${term} `.repeat(Math.ceil(size / (term.length + 1)));
const boundary = `b${RUN}`;
const raw = [
`From: ${ENT.bob}`, `To: ${account}`, `Subject: ${subject}`, `Date: ${new Date().toUTCString()}`, `Message-ID: <${RUN}att@example.org>`, 'MIME-Version: 1.0',
`Content-Type: multipart/mixed; boundary="${boundary}"`, '', `--${boundary}`, 'Content-Type: text/plain', '', `See the file ${RUN}`, `--${boundary}`,
'Content-Type: text/plain; name="kept.txt"', 'Content-Disposition: attachment; filename="kept.txt"', 'Content-Transfer-Encoding: 8bit', '', content, `--${boundary}--`, '',
].join('\r\n');
const auth = 'Basic ' + Buffer.from(`${account}:${process.env.IT_ENT_PASSWORD}`).toString('base64');
const upload = await fetch(`${JMAP_URL}/jmap/upload/${client.accountId}/`, { method: 'POST', headers: { Authorization: auth, 'Content-Type': 'message/rfc822' }, body: raw });
if (!upload.ok) throw new Error(`upload: ${upload.status}`);
const { blobId } = await upload.json() as { blobId: string };
const boxes = await client.request([['Mailbox/query', { accountId: client.accountId, filter: { role: 'inbox' } }, '0']]);
const inbox = boxes.methodResponses[0][1].ids[0] as string;
const done = await client.request([['Email/import', { accountId: client.accountId, emails: { a: { blobId, mailboxIds: { [inbox]: true }, keywords: {}, receivedAt: new Date().toISOString().replace(/\.\d+Z$/, 'Z') } } }, '0']]);
if (!done.methodResponses[0][1].created?.a) throw new Error(`import: ${JSON.stringify(done.methodResponses[0][1])}`);
}
/** How many bytes of attachments the settings page says are kept, roughly: it's what the store counts. */
async function keptAttachmentBytes(page: Page): Promise<number> {
const row = page.getByTestId('offline-usage').locator('[data-part="attachments"] dd');
if ((await row.count()) === 0) return 0;
const match = (await row.innerText()).replace(',', '.').match(/([\d.]+)\s*(B|KB|MB|GB)/);
return match ? Number(match[1]) * { B: 1, KB: 1024, MB: 1024 ** 2, GB: 1024 ** 3 }[match[2] as 'B' | 'KB' | 'MB' | 'GB'] : 0;
}
test('nothing of a kept message is readable on the device', async ({ browser }) => {
const subject = `Rawcheck ${RUN} ${RUN}quokka`;
const text = `Rawbody ${RUN} ${RUN}wombat`;
const inFile = `${RUN}platypus`;
await deliverAndWait(subject, text);
await importWithAttachment(ENT.alice, `Rawfile ${RUN}`, inFile, 300_000);
const page = await userPage(browser, ENT.alice);
await toMail(page);
await openMessage(page, subject);
/** Everything this origin has stored, as raw bytes where it's a file, looked through for a term. */
const findIn = (term: string) => page.evaluate(async (needle) => {
const hits: string[] = [];
const encoded = new TextEncoder().encode(needle);
const has = (bytes: Uint8Array) => {
outer: for (let i = 0; i <= bytes.length - encoded.length; i += 1) {
for (let j = 0; j < encoded.length; j += 1) if (bytes[i + j] !== encoded[j]) continue outer;
return true;
}
return false;
};
let files = 0;
let bytesRead = 0;
const walk = async (dir: FileSystemDirectoryHandle, path: string) => {
for await (const [name, handle] of (dir as unknown as { entries(): AsyncIterable<[string, FileSystemHandle]> }).entries()) {
if (handle.kind === 'directory') { await walk(handle as FileSystemDirectoryHandle, `${path}/${name}`); continue; }
// Works while the worker holds the handles.
const bytes = new Uint8Array(await (await (handle as FileSystemFileHandle).getFile()).arrayBuffer());
files += 1;
bytesRead += bytes.length;
if (has(bytes)) hits.push(`opfs:${path}/${name}`);
}
};
await walk(await navigator.storage.getDirectory(), '');
const flatten = (value: unknown): string => {
if (value instanceof ArrayBuffer) return new TextDecoder('latin1').decode(value);
if (ArrayBuffer.isView(value)) return new TextDecoder('latin1').decode(value as Uint8Array);
if (typeof value === 'string') return value;
if (Array.isArray(value)) return value.map(flatten).join(' ');
if (value && typeof value === 'object' && !(value instanceof CryptoKey)) return Object.entries(value).map(([key, inner]) => `${key} ${flatten(inner)}`).join(' ');
return String(value);
};
for (const info of await indexedDB.databases()) {
if (!info.name) continue;
const db = await new Promise<IDBDatabase>((resolve, reject) => { const r = indexedDB.open(info.name!); r.onsuccess = () => resolve(r.result); r.onerror = () => reject(r.error); });
for (const store of db.objectStoreNames) {
const rows = await new Promise<unknown[]>((resolve) => { const r = db.transaction(store).objectStore(store).getAll(); r.onsuccess = () => resolve(r.result); r.onerror = () => resolve([]); });
if (flatten(rows).includes(needle)) hits.push(`indexeddb:${info.name}/${store}`);
}
db.close();
}
for (const [label, storage] of [['localStorage', localStorage], ['sessionStorage', sessionStorage]] as const) {
for (let i = 0; i < storage.length; i += 1) {
const key = storage.key(i)!;
if (`${key} ${storage.getItem(key)}`.includes(needle)) hits.push(`${label}:${key}`);
}
}
for (const name of await caches.keys()) {
const cache = await caches.open(name);
for (const request of await cache.keys()) {
const response = await cache.match(request);
if (response && (await response.clone().text()).includes(needle)) hits.push(`cache:${name}:${request.url}`);
}
}
return { hits, files, bytesRead };
}, term);
// The check can see plaintext: without the store, the boot snapshot has the subject.
await expect.poll(async () => (await findIn(subject)).hits, { timeout: 15_000 }).toContain('localStorage:email-snapshot');
await enableOffline(page);
await expect(async () => {
const client = await jmapAs(ENT.alice);
// The default window: the old message of the cap test stays out.
const after = new Date(Date.now() - 90 * 86_400_000).toISOString().replace(/\.\d+Z$/, 'Z');
const total = await client.request([['Email/query', { accountId: client.accountId, filter: { after }, calculateTotal: true, limit: 0 }, '0']]);
expect(await keptMessages(page)).toBeGreaterThanOrEqual(total.methodResponses[0][1].total);
}).toPass({ timeout: 180_000, intervals: [2000] });
await toMail(page);
await openMessage(page, subject);
await expect(body(page)).toContainText(text, { timeout: 20_000 });
await page.waitForTimeout(1500);
// The file has to be on the device for this to say anything: 300 KB of it.
await page.goto('/settings/backup/offline');
await expect.poll(() => keptAttachmentBytes(page), { timeout: 180_000, intervals: [2000] }).toBeGreaterThan(300_000);
for (const term of [subject, text, `${RUN}quokka`, `${RUN}wombat`, inFile]) {
const { hits, files, bytesRead } = await findIn(term);
expect(files, 'the store has its files').toBeGreaterThan(0);
expect(bytesRead, 'and they hold something').toBeGreaterThan(100_000);
expect(hits, `plaintext "${term}"`).toEqual([]);
}
await page.goto('/mail');
await expect(page.locator('[data-testid="email-list-item"]', { hasText: subject }).first()).toBeVisible({ timeout: 60_000 });
await page.waitForTimeout(1500);
expect((await findIn(subject)).hits).toEqual([]);
await page.context().close();
});
test('Keep offline in the Files app keeps the file, and the settings list it', async ({ browser }) => {
const name = `kept-${RUN}.txt`;
const client = await jmapAs(ENT.alice);
const auth = 'Basic ' + Buffer.from(`${ENT.alice}:${process.env.IT_ENT_PASSWORD}`).toString('base64');
const upload = await fetch(`${JMAP_URL}/jmap/upload/${client.accountId}/`, { method: 'POST', headers: { Authorization: auth, 'Content-Type': 'text/plain' }, body: `Kept file ${RUN}` });
expect(upload.ok).toBe(true);
const { blobId } = await upload.json() as { blobId: string };
const made = await client.request([['FileNode/set', { accountId: client.accountId, create: { f: { name, blobId, parentId: null, type: 'text/plain' } } }, '0']], ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:filenode']);
const created = made.methodResponses[0][1].created?.f as { id: string } | undefined;
expect(created, JSON.stringify(made.methodResponses[0][1])).toBeTruthy();
try {
const page = await userPage(browser, ENT.alice);
await enableOffline(page);
await page.goto('/files');
const row = page.getByText(name, { exact: true }).first();
await expect(row).toBeVisible({ timeout: 60_000 });
await row.click({ button: 'right' });
await page.getByRole('menuitem', { name: 'Keep offline' }).click();
await page.goto('/settings/backup/offline');
await expect(page.getByTestId('offline-kept-file').filter({ hasText: name })).toHaveAttribute('data-kept', 'true', { timeout: 120_000 });
await page.getByTestId('offline-kept-files').scrollIntoViewIfNeeded();
await shot(page, 'do-storage-page-kept-files');
await page.goto('/files');
await expect(row).toBeVisible({ timeout: 60_000 });
await row.click({ button: 'right' });
await page.getByRole('menuitem', { name: 'Stop keeping offline' }).click();
await page.goto('/settings/backup/offline');
await expect(page.getByTestId('offline-account')).toBeVisible({ timeout: 60_000 });
await expect(page.getByTestId('offline-kept-file').filter({ hasText: name })).toHaveCount(0, { timeout: 30_000 });
await page.context().close();
} finally {
await client.request([['FileNode/set', { accountId: client.accountId, destroy: [created!.id] }, '0']], ['urn:ietf:params:jmap:core', 'urn:ietf:params:jmap:filenode']).catch(() => {});
}
});
test('the admin page saves the caps and no longer promises 30 days or 250 MB', async ({ browser }) => {
const admin = await adminContext(browser);
const page = await admin.newPage();
await page.goto('/admin/org-devices');
const days = page.getByTestId('org-devices-offlineMaxDays');
await expect(days).toBeVisible({ timeout: 60_000 });
await expect(page.locator('body')).not.toContainText('250 MB');
await days.fill('45');
await page.getByTestId('org-devices-noOfflineAttachments').click();
await page.getByRole('button', { name: 'Save', exact: true }).first().click();
await expect.poll(async () => ((await getPolicy(admin)).organisation as { devices?: { offlineMaxDays?: number; noOfflineAttachments?: boolean } }).devices, { timeout: 15_000 })
.toMatchObject({ offlineMaxDays: 45, noOfflineAttachments: true });
await shot(page, 'do-admin-offline-caps');
await putPolicy(admin, { ...original, scopes: [] });
await admin.close();
});
test('a deploy makes the open tab offer a reload and serves no stale chunks', async ({ browser }) => {
const redeploy = process.env.IT_OFFLINE_REDEPLOY;
test.skip(!redeploy, 'set IT_OFFLINE_REDEPLOY to the command that deploys a second build (enterprise-rig/scratch/f-redeploy.sh)');
+51
View File
@@ -89,6 +89,57 @@ describe('which account decides', () => {
});
});
describe('offline caps', () => {
const scoped = (id: string, match: object, devices: Record<string, unknown>) => ({ id, label: id, match, policy: { organisation: { devices } } });
function withPolicy(organisationDevices: Record<string, unknown>, scopes: unknown[]) {
usePolicyStore.setState({ rawPolicy: { ...DEFAULT_POLICY, organisation: { devices: organisationDevices }, scopes } as never, loaded: true });
usePolicyStore.getState().setPolicySubject(null);
usePolicyStore.getState().setPolicySubject(ALICE);
}
it('stay numbers across signed-in accounts, the lowest one winning', () => {
withPolicy({}, [
scoped('a', { groups: ['team@example.org'] }, { offlineMaxDays: 90, offlineMaxTotalMb: 500 }),
]);
const strictest = getStrictestDevices();
expect(strictest.offlineMaxDays).toBe(90);
expect(strictest.offlineMaxTotalMb).toBe(500);
// Carol has no cap: 0 means none, and must not read as the lowest.
expect(devicesForAccount('acc-c').offlineMaxDays).toBe(0);
});
it('a scope can lower a cap but not raise it', () => {
withPolicy({ offlineMaxDays: 60, offlineMaxAttachmentMb: 10 }, [
scoped('up', { groups: ['team@example.org'] }, { offlineMaxDays: 365, offlineMaxAttachmentMb: 0 }),
]);
expect(devicesForAccount('acc-a')).toMatchObject({ offlineMaxDays: 60, offlineMaxAttachmentMb: 10 });
withPolicy({ offlineMaxDays: 60 }, [
scoped('down', { groups: ['team@example.org'] }, { offlineMaxDays: 7 }),
]);
expect(devicesForAccount('acc-a').offlineMaxDays).toBe(7);
});
it('a scope cannot allow attachments that another one forbids', () => {
withPolicy({ noOfflineAttachments: true }, [
scoped('up', { groups: ['team@example.org'] }, { noOfflineAttachments: false }),
]);
expect(devicesForAccount('acc-a').noOfflineAttachments).toBe(true);
});
it('noOffline stays last value wins, as before', () => {
withPolicy({ noOffline: true }, [
scoped('allow', { groups: ['team@example.org'] }, { noOffline: false }),
]);
expect(devicesForAccount('acc-a').noOffline).toBe(false);
});
it('drops what is not a number, and negative caps mean none', () => {
withPolicy({ offlineMaxDays: 'lots', offlineMaxTotalMb: -5 }, []);
expect(devicesForAccount('acc-a')).toMatchObject({ offlineMaxDays: 0, offlineMaxTotalMb: 0 });
});
});
describe('the backstop', () => {
it('refuses a download, says so, and never builds the anchor', () => {
const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
+199
View File
@@ -0,0 +1,199 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { InstalledPlugin } from '@/lib/plugin-types';
import { EMPTY_POLICY_LAYERS, resolvePolicies, type PolicyLayer } from '@/lib/desktop/policy-schema';
/**
* PluginsMode on the client: the only gate there is on an instance. And the trap: a
* plugin missing from /api/plugins counts as removed by the admin, which deletes its
* bundle, data and connected accounts. Tightening a policy must not do that.
*/
vi.mock('@/lib/plugin-storage', () => ({
pluginStorage: {
saveCode: vi.fn().mockResolvedValue(undefined),
getCode: vi.fn().mockResolvedValue('code'),
codeIds: vi.fn().mockResolvedValue(['kept', 'refused-one']),
deleteCode: vi.fn().mockResolvedValue(undefined),
saveThemeCSS: vi.fn().mockResolvedValue(undefined),
getThemeCSS: vi.fn().mockResolvedValue(null),
deleteThemeCSS: vi.fn().mockResolvedValue(undefined),
savePreview: vi.fn().mockResolvedValue(undefined),
getPreview: vi.fn().mockResolvedValue(null),
deletePreview: vi.fn().mockResolvedValue(undefined),
},
fileStorage: { clearFiles: vi.fn().mockResolvedValue(undefined) },
}));
vi.mock('@/lib/plugin-validator', () => ({ extractPlugin: vi.fn() }));
vi.mock('@/lib/plugin-loader', () => ({
loadPlugin: vi.fn().mockResolvedValue(undefined),
deactivatePlugin: vi.fn(),
setPluginStoreAccessor: vi.fn(),
setupAutoDisable: vi.fn(),
setSandboxLocale: vi.fn(),
forgetSlotOffers: vi.fn(),
}));
vi.mock('@/lib/plugin-hooks', () => ({ removeAllPluginHooks: vi.fn() }));
const mocks = vi.hoisted(() => ({ apiFetch: vi.fn(), downloadManagedBundle: vi.fn() }));
// In the app the bridge is there before any page code runs, and the store subscribes
// when it's first imported. So it's here before the imports below.
const harness = vi.hoisted(() => {
const state = {
current: null as unknown,
listeners: [] as ((snapshot: unknown) => void)[],
};
(window as unknown as { bulwarkDesktop: unknown }).bulwarkDesktop = {
version: 1,
platform: 'win32',
policy: {
get: () => state.current,
onChange: (listener: (snapshot: unknown) => void) => {
state.listeners.push(listener);
return () => {};
},
},
};
return state;
});
vi.mock('@/lib/browser-navigation', () => ({ apiFetch: (...args: unknown[]) => mocks.apiFetch(...args) }));
vi.mock('@/lib/plugin-sandbox/host-bridge', () => ({
forEachInstance: () => {},
snapshotRegionalSettings: vi.fn((locale: string) => ({ locale, dateLocale: locale, hour12: false, timeZone: 'UTC', firstDayOfWeek: 1 })),
}));
vi.mock('@/lib/plugin-sandbox/bundle-fetch', () => ({ downloadManagedBundle: (...args: unknown[]) => mocks.downloadManagedBundle(...args) }));
import { register as registerActive, deregister as deregisterActive } from '@/lib/plugin-sandbox/registry';
import { usePluginStore } from '@/stores/plugin-store';
import { pluginStorage } from '@/lib/plugin-storage';
import { deactivatePlugin, forgetSlotOffers, loadPlugin } from '@/lib/plugin-loader';
import { extractPlugin } from '@/lib/plugin-validator';
function snapshotOf(forcedMachine: PolicyLayer) {
return resolvePolicies({ ...EMPTY_POLICY_LAYERS, forcedMachine });
}
function setPolicy(forced: PolicyLayer) {
harness.current = snapshotOf(forced);
for (const listener of harness.listeners) listener(harness.current);
}
function plugin(overrides: Partial<InstalledPlugin> = {}): InstalledPlugin {
return {
id: 'refused-one', name: 'R', version: '1.0.0', author: 'A', description: '', type: 'hook', entrypoint: 'index.js',
permissions: [], enabled: true, status: 'running', managed: true, settings: { mine: 'kept' }, ...overrides,
};
}
const listed = (id: string, extra: object = {}) => ({
id, name: id, version: '1.0.0', author: 'A', description: '', type: 'hook', entrypoint: 'index.js', permissions: [], forceEnabled: false, bundleHash: 'hash', ...extra,
});
function serve(plugins: unknown[]) {
mocks.apiFetch.mockImplementation(async () => new Response(JSON.stringify({ plugins, themes: [] })));
}
beforeEach(() => {
vi.clearAllMocks();
usePluginStore.setState({ plugins: [], initialized: false });
setPolicy({});
localStorage.clear();
usePluginStore.setState({ plugins: [], initialized: false });
});
describe('tightening the policy destroys nothing', () => {
function expectNothingDeleted(id: string) {
expect(usePluginStore.getState().plugins.find((p) => p.id === id)).toMatchObject({ id, settings: { mine: 'kept' } });
expect(localStorage.getItem(`plugin:${id}:@acct:notes`)).toBe('"secret"');
expect(pluginStorage.deleteCode).not.toHaveBeenCalledWith(id);
expect(pluginStorage.deletePreview).not.toHaveBeenCalledWith(id);
expect(forgetSlotOffers).not.toHaveBeenCalledWith(id);
}
beforeEach(() => {
localStorage.setItem('server-managed-plugin-ids', JSON.stringify(['refused-one']));
localStorage.setItem('plugin:refused-one:@acct:notes', '"secret"');
usePluginStore.setState({ plugins: [plugin()] });
});
it('the embedded server flags it: record, data and bundle stay, and nothing is downloaded', async () => {
setPolicy({ PluginsMode: 'allowlist', PluginAllowlist: ['kept'] });
serve([listed('refused-one', { refused: true, version: '2.0.0', bundleHash: 'changed' })]);
await usePluginStore.getState().initializePlugins();
expectNothingDeleted('refused-one');
expect(usePluginStore.getState().plugins[0].version).toBe('1.0.0');
expect(mocks.downloadManagedBundle).not.toHaveBeenCalled();
});
it('an instance knows nothing of the policy and lists it plainly: the client refuses by itself', async () => {
setPolicy({ PluginsMode: 'off' });
serve([listed('refused-one', { version: '2.0.0', bundleHash: 'changed' })]);
await usePluginStore.getState().initializePlugins();
expectNothingDeleted('refused-one');
expect(mocks.downloadManagedBundle).not.toHaveBeenCalled();
});
it('a plugin the admin really removed still goes, with the policy on', async () => {
setPolicy({ PluginsMode: 'allowlist', PluginAllowlist: ['kept'] });
serve([]);
await usePluginStore.getState().initializePlugins();
expect(usePluginStore.getState().plugins.some((p) => p.id === 'refused-one')).toBe(false);
expect(localStorage.getItem('plugin:refused-one:@acct:notes')).toBeNull();
});
it('a change while running stops it and keeps everything, and lifting it starts it again', async () => {
registerActive({ plugin: plugin(), code: 'code', tier: 'untrusted', background: { destroy: vi.fn() }, slotOffers: [], hookDisposables: [] } as never);
setPolicy({ PluginsMode: 'off' });
expect(deactivatePlugin).toHaveBeenCalledWith('refused-one');
expect(usePluginStore.getState().plugins[0]).toMatchObject({ enabled: true, status: 'error', error: 'device-policy' });
expectNothingDeleted('refused-one');
deregisterActive('refused-one');
setPolicy({});
expect(loadPlugin).toHaveBeenCalledWith(expect.objectContaining({ id: 'refused-one' }));
expect(usePluginStore.getState().plugins[0].error).toBeUndefined();
});
});
describe('the client gates', () => {
it('a new plugin the policy refuses isn\'t added or downloaded', async () => {
setPolicy({ PluginsMode: 'allowlist', PluginAllowlist: ['kept'] });
serve([listed('refused-one')]);
mocks.downloadManagedBundle.mockResolvedValue('code');
await usePluginStore.getState().initializePlugins();
expect(usePluginStore.getState().plugins).toEqual([]);
expect(mocks.downloadManagedBundle).not.toHaveBeenCalled();
});
it('an allowed one is', async () => {
setPolicy({ PluginsMode: 'allowlist', PluginAllowlist: ['kept'] });
serve([listed('kept')]);
mocks.downloadManagedBundle.mockResolvedValue('code');
await usePluginStore.getState().initializePlugins();
expect(usePluginStore.getState().plugins.map((p) => p.id)).toEqual(['kept']);
});
it('with a list nobody adds a package, even one naming a listed id', async () => {
setPolicy({ PluginsMode: 'allowlist', PluginAllowlist: ['kept'] });
vi.mocked(extractPlugin).mockResolvedValue({ valid: true, errors: [], warnings: [], manifest: { id: 'kept' }, code: 'x' } as never);
const result = await usePluginStore.getState().installPlugin(new File(['x'], 'p.zip'));
expect(result).toMatchObject({ success: false, reason: 'device-policy' });
expect(usePluginStore.getState().plugins).toEqual([]);
expect(pluginStorage.saveCode).not.toHaveBeenCalled();
});
it('a package the user added earlier under a listed id doesn\'t run', async () => {
setPolicy({ PluginsMode: 'allowlist', PluginAllowlist: ['refused-one'] });
usePluginStore.setState({ plugins: [plugin({ managed: false, enabled: false, status: 'installed' })] });
await usePluginStore.getState().enablePlugin('refused-one');
expect(usePluginStore.getState().plugins[0]).toMatchObject({ enabled: false, status: 'error', error: 'device-policy' });
});
it('turning one on is refused, and says why', async () => {
setPolicy({ PluginsMode: 'off' });
usePluginStore.setState({ plugins: [plugin({ enabled: false, status: 'installed' })] });
await usePluginStore.getState().enablePlugin('refused-one');
expect(usePluginStore.getState().plugins[0]).toMatchObject({ enabled: false, status: 'error', error: 'device-policy' });
expect(loadPlugin).not.toHaveBeenCalled();
});
});
+11
View File
@@ -196,6 +196,17 @@ describe('api.host.features.keystore', () => {
});
});
describe('api.host.features.blockExternalContent', () => {
it('is there for a plugin that takes over rendering', () => {
expect(snapshotHostFeatures(plugin({ permissions: ['email:render-takeover'] })).blockExternalContent).toBe(true);
});
it('is absent for any other, and without a plugin', () => {
expect(snapshotHostFeatures(plugin({ permissions: ['email:read'] }))).not.toHaveProperty('blockExternalContent');
expect(snapshotHostFeatures()).not.toHaveProperty('blockExternalContent');
});
});
describe('the manifest', () => {
const BASE = { id: 'keystore-test', name: 'P', version: '1.0.0', author: 'a', description: 'd', type: 'hook' };
@@ -102,6 +102,36 @@ function lastStatus() {
return statuses[statuses.length - 1];
}
describe('loadSandboxedPlugin under the OS policy of the device', () => {
it('starts nothing the policy refuses: no frame, no download', async () => {
const { EMPTY_POLICY_LAYERS, resolvePolicies } = await import('../desktop/policy-schema');
const snapshot = (forcedMachine: Record<string, unknown>) => resolvePolicies({ ...EMPTY_POLICY_LAYERS, forcedMachine });
const bridgeWith = (current: ReturnType<typeof snapshot>) => ({ version: 1, platform: 'win32' as const, policy: { get: () => current, onChange: () => () => {} } });
window.bulwarkDesktop = bridgeWith(snapshot({ PluginsMode: 'allowlist', PluginAllowlist: ['other-plugin'] }));
mocks.getCode.mockResolvedValue(CODE);
try {
await loadSandboxedPlugin(plugin());
expect(lastStatus()).toMatchObject({ id: 'managed-plugin', status: 'error', error: 'device-policy' });
expect(mocks.createBackgroundInstance).not.toHaveBeenCalled();
expect(mocks.downloadManagedBundle).not.toHaveBeenCalled();
expect(mocks.getCode).not.toHaveBeenCalled();
window.bulwarkDesktop = bridgeWith(snapshot({ PluginsMode: 'allowlist', PluginAllowlist: ['managed-plugin'] }));
await loadSandboxedPlugin(plugin());
expect(lastStatus()).toMatchObject({ id: 'managed-plugin', status: 'running' });
unloadSandboxedPlugin('managed-plugin');
// The same id on a package of the user's own doesn't pass.
mocks.createBackgroundInstance.mockClear();
await loadSandboxedPlugin(plugin({ managed: false }));
expect(lastStatus()).toMatchObject({ id: 'managed-plugin', status: 'error', error: 'device-policy' });
expect(mocks.createBackgroundInstance).not.toHaveBeenCalled();
} finally {
window.bulwarkDesktop = undefined;
}
});
});
describe('loadSandboxedPlugin in the no-third-party mode (#903)', () => {
it('refuses a plugin that declares network access and runs one that does not', async () => {
const { setNoThirdPartyTraffic } = await import('../third-party-mode');
+1
View File
@@ -26,6 +26,7 @@ describe('purgeSignedOutData', () => {
for (const key of [
'email-snapshot', 'pro-tabs', 'search-history-storage', 'calendar-storage',
'files-favorites', 'files-recent-files', 'files-path-stack', 'bulwark-delegate-names',
'bulwark-offline-accounts', 'bulwark-offline-scope', 'bulwark-offline-health',
'settings-storage', 'theme-storage', 'locale-storage', 'template-storage', 'files-view-mode',
]) localStorage.setItem(key, 'x');
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import { DEFAULT_POLICY, type SettingsPolicy } from '../types';
import { ALL_DEVICE_LIMITS, strictestOfAllScopes } from '../server-device-policy';
// The offline caps are numbers in a section that used to hold only booleans.
const scope = (id: string, devices: Record<string, unknown>) => ({ id, label: id, match: { domains: [`${id}.example.org`] }, policy: { organisation: { devices } } });
describe('strictest of all scopes', () => {
it('keeps the lowest cap, and a boolean switch still turns on', () => {
const policy = {
...DEFAULT_POLICY,
organisation: { devices: { offlineMaxDays: 90 } },
scopes: [scope('a', { offlineMaxDays: 30, offlineMaxTotalMb: 500 }), scope('b', { offlineMaxDays: 60, noPrint: true })],
} as unknown as SettingsPolicy;
expect(strictestOfAllScopes(policy)).toMatchObject({ offlineMaxDays: 30, offlineMaxTotalMb: 500, offlineMaxAttachmentMb: 0, noPrint: true, noExport: false });
});
it('a scope saying 0 does not lift a cap', () => {
const policy = { ...DEFAULT_POLICY, organisation: { devices: { offlineMaxDays: 90 } }, scopes: [scope('a', { offlineMaxDays: 0 })] } as unknown as SettingsPolicy;
expect(strictestOfAllScopes(policy).offlineMaxDays).toBe(90);
});
});
describe('everything on', () => {
it('holds booleans as true and caps as the lowest number', () => {
expect(ALL_DEVICE_LIMITS).toMatchObject({ noOffline: true, noDownloads: true, noOfflineAttachments: true, offlineMaxDays: 1, offlineMaxAttachmentMb: 1, offlineMaxTotalMb: 1 });
});
});
+2 -1
View File
@@ -13,6 +13,7 @@ import { getPluginRegistry } from './plugin-registry';
import { sanitizeFrameOrigins } from '../extension-origins';
import { isNoThirdPartyTrafficOn } from './third-party-mode';
import { onSharedChange } from '@/lib/shared-store/changes';
import { pluginRefusedByDevicePolicy } from '@/lib/desktop/server-policy';
export {
isValidFrameOrigin,
@@ -79,7 +80,7 @@ export async function getEnabledPluginFrameOrigins(): Promise<string[]> {
const seen = new Set<string>();
const out: string[] = [];
for (const plugin of [...registry.plugins, ...devPlugins]) {
if (!plugin.enabled) continue;
if (!plugin.enabled || pluginRefusedByDevicePolicy(plugin.id)) continue;
const origins = sanitizeFrameOrigins(plugin.frameOrigins);
for (const o of origins) {
const key = o.toLowerCase();
+57 -4
View File
@@ -168,6 +168,13 @@ export interface OrganisationPolicy {
pushWithoutContent: boolean;
watermark: boolean;
noOffline: boolean;
/** How far back a device keeps mail, in days. 0 is no cap. Like the other caps, a scope can only lower it. */
offlineMaxDays: number;
noOfflineAttachments: boolean;
/** The largest attachment or file a device keeps, in MB. 0 is no cap. */
offlineMaxAttachmentMb: number;
/** All attachments and files of one account on a device, in MB. 0 is no cap. */
offlineMaxTotalMb: number;
};
calendars: {
/** Adds up across scopes by id. */
@@ -192,6 +199,7 @@ export const DEFAULT_ORGANISATION_POLICY: OrganisationPolicy = {
devices: {
noDownloads: false, noExport: false, noPrint: false, noInstallPrompt: false,
pushWithoutContent: false, watermark: false, noOffline: false,
offlineMaxDays: 0, noOfflineAttachments: false, offlineMaxAttachmentMb: 0, offlineMaxTotalMb: 0,
},
calendars: { feeds: [], freeBusyGroup: '' },
};
@@ -208,6 +216,8 @@ export type DeepPartial<T> = {
const MAX_TEXT = 64 * 1024;
const MAX_LIBRARY = 200;
const MAX_DOMAINS = 500;
const MAX_OFFLINE_DAYS = 3650;
const MAX_OFFLINE_MB = 1024 * 1024;
// Same shape as the branding host rule, without the wildcard: a bare domain.
const DOMAIN_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
@@ -530,6 +540,10 @@ export function sanitizeOrganisationPartial(raw: unknown): DeepPartial<Organisat
pushWithoutContent: bool(dev.pushWithoutContent),
watermark: bool(dev.watermark),
noOffline: bool(dev.noOffline),
offlineMaxDays: count(dev.offlineMaxDays, MAX_OFFLINE_DAYS),
noOfflineAttachments: bool(dev.noOfflineAttachments),
offlineMaxAttachmentMb: count(dev.offlineMaxAttachmentMb, MAX_OFFLINE_MB),
offlineMaxTotalMb: count(dev.offlineMaxTotalMb, MAX_OFFLINE_MB),
});
const cal = section('calendars');
@@ -538,6 +552,42 @@ export function sanitizeOrganisationPartial(raw: unknown): DeepPartial<Organisat
return out;
}
/** The smaller of two caps, where 0 is no cap. */
export function tighterCap(a: number, b: number): number {
if (!(a > 0)) return b > 0 ? b : 0;
return b > 0 ? Math.min(a, b) : a;
}
/**
* Device keys where the strictest value wins across scopes instead of the
* last one: a group scope must not raise what a domain scope lowered.
* noOffline isn't one: a scope may allow it where Everyone forbids it.
*/
const STRICTEST_DEVICE_MERGE: Partial<Record<keyof OrganisationPolicy['devices'], (a: never, b: never) => unknown>> = {
offlineMaxDays: tighterCap,
offlineMaxAttachmentMb: tighterCap,
offlineMaxTotalMb: tighterCap,
noOfflineAttachments: (a: boolean, b: boolean) => a || b,
};
/**
* Every limit either side sets: for a page that isn't about one account, or a
* login whose scopes can't be worked out. Numbers are caps, so a key may never
* become `true` here.
*/
export function strictestDevices(
base: OrganisationPolicy['devices'],
other: Partial<OrganisationPolicy['devices']>,
): OrganisationPolicy['devices'] {
const out = { ...base } as Record<string, boolean | number>;
for (const [key, value] of Object.entries(other)) {
const current = out[key];
if (typeof current === 'number') out[key] = tighterCap(current, typeof value === 'number' ? value : 0);
else if (value === true) out[key] = true;
}
return out as OrganisationPolicy['devices'];
}
/** Arrays that add up across scopes by id instead of replacing. */
const ADDITIVE_LISTS: Record<string, string> = { templates: 'library', vacationTemplates: 'library', calendars: 'feeds' };
@@ -553,10 +603,10 @@ function normaliseMerged(policy: OrganisationPolicy): OrganisationPolicy {
/**
* Merges `override` into `base` per leaf of plain objects; arrays and values
* replace. Exceptions: the lists in ADDITIVE_LISTS add up by id, and the
* sections in WHOLE_SECTIONS are replaced as a whole (a scope's own label
* scheme or announcement never mixes with the inherited one), with the
* built-in defaults for keys the override leaves out.
* replace. Exceptions: the lists in ADDITIVE_LISTS add up by id, the sections
* in WHOLE_SECTIONS are replaced as a whole (a scope's own label scheme or
* announcement never mixes with the inherited one), with the built-in defaults
* for keys the override leaves out, and the offline caps keep the strictest.
*/
export function mergeOrganisation(
base: OrganisationPolicy,
@@ -577,6 +627,9 @@ export function mergeOrganisation(
const byId = new Map((merged[key] as Array<{ id: string }>).map((t) => [t.id, t]));
for (const item of value as Array<{ id: string }>) byId.set(item.id, item);
merged[key] = [...byId.values()];
} else if (sectionKey === 'devices' && STRICTEST_DEVICE_MERGE[key as keyof OrganisationPolicy['devices']]) {
const pick = STRICTEST_DEVICE_MERGE[key as keyof OrganisationPolicy['devices']] as (a: unknown, b: unknown) => unknown;
merged[key] = pick(merged[key], value);
} else {
merged[key] = value;
}
+3
View File
@@ -157,6 +157,8 @@ export const OUTGOING_HOSTS: readonly OutgoingHost[] = [
* has to be looked at again.
*/
export const NON_REQUEST_HOSTS: readonly { host: string; files: readonly string[]; why: string }[] = [
{ host: 'schemas.microsoft.com', why: 'XML namespace of the ADMX templates', files: ['lib/desktop/policy-templates.ts'] },
{ host: 'www.apple.com', why: 'DTD named in the sample .mobileconfig', files: ['lib/desktop/policy-templates.ts'] },
{ host: 'bulwarkmail.org', why: 'docs links, comments and the contact URL in User-Agent headers', files: ['app/(main)/admin/_pages/telemetry.tsx', 'app/api/fetch-ical/route.ts', 'app/api/translate/route.ts', 'components/admin/org/sections/recovery-section.tsx', 'components/admin/org/sections/company-calendars-section.tsx', 'components/admin/org/sections/free-busy-section.tsx', 'components/admin/org/sections/labels-section.tsx', 'components/admin/org/sections/out-of-office-calendar-section.tsx', 'components/admin/org/sections/phishing-report-section.tsx', 'instrumentation.node.ts', 'lib/telemetry/state.ts', 'lib/telemetry/types.ts', 'app/(main)/admin/_pages/monitoring.tsx', 'lib/admin/types.ts', 'lib/error-reporting/client.ts', 'lib/error-reporting/lite.ts', 'lib/error-reporting/scrub.ts', 'instrumentation.ts', 'app/api/health/live/route.ts', 'app/api/health/ready/route.ts', 'app/api/metrics/route.ts', 'lib/metrics/index.ts', 'lib/tracing/config.ts', 'lib/tracing/scrub.ts', 'lib/tracing/start.ts'] },
{ host: 'bulwarkmail.org', why: 'docs links of the Notices, Devices (offline mode too) and out-of-office template pages', files: ['components/admin/org/sections/announcement-section.tsx', 'components/admin/org/sections/devices-section.tsx', 'components/admin/org/sections/offline-section.tsx', 'components/admin/org/sections/retention-section.tsx', 'components/admin/org/sections/vacation-templates-section.tsx'] },
{ host: 'bulwarkmail.com', why: 'JMAP capability URI', files: ['lib/jmap/client.ts'] },
@@ -182,6 +184,7 @@ export const NON_REQUEST_HOSTS: readonly { host: string; files: readonly string[
* listed with the reason.
*/
export const BUILT_URL_FILES: readonly { file: string; why: string }[] = [
{ file: 'lib/desktop/policy-schema.ts', why: 'parses a host out of a policy value to check it, and fetches nothing' },
{ file: 'app/(main)/admin/_pages/marketplace-detail.tsx', why: "link to an extension's source host, from the directory's answer" },
{ file: 'app/(main)/setup/page.tsx', why: "the instance's own origin, and scheme checks of the admin-entered server URL" },
{ file: 'app/api/plugin-secrets/route.ts', why: "scheme check of a credential's server URL" },
+6 -7
View File
@@ -14,7 +14,7 @@
import { createHash } from 'node:crypto';
import { configManager } from './config-manager';
import { DEFAULT_ORGANISATION_POLICY, type OrganisationPolicy } from './organisation-policy';
import { DEFAULT_ORGANISATION_POLICY, strictestDevices, type OrganisationPolicy } from './organisation-policy';
import { resolvePolicy, type PolicySubject } from './policy-scopes';
import type { SettingsPolicy } from './types';
import { fetchJmapSession } from '@/lib/stalwart/jmap-api';
@@ -75,20 +75,19 @@ export function strictDeviceSubject(creds: DeviceCreds): Promise<PolicySubject |
return subject;
}
/** Every switch that Everyone or any scope turns on: for a login whose scopes can't be worked out. */
/** Every switch that Everyone or any scope turns on, and the lowest of each cap: for a login whose scopes can't be worked out. */
export function strictestOfAllScopes(policy: SettingsPolicy): ServerDevicePolicy {
const out: ServerDevicePolicy = { ...DEFAULT_ORGANISATION_POLICY.devices, ...(resolvePolicy(policy, null).policy.organisation?.devices ?? {}) };
let out: ServerDevicePolicy = { ...DEFAULT_ORGANISATION_POLICY.devices, ...(resolvePolicy(policy, null).policy.organisation?.devices ?? {}) };
for (const scope of policy.scopes ?? []) {
const devices = scope.policy?.organisation?.devices as Partial<ServerDevicePolicy> | undefined;
if (!devices) continue;
for (const key of Object.keys(out) as (keyof ServerDevicePolicy)[]) if (devices[key] === true) out[key] = true;
if (devices) out = strictestDevices(out, devices);
}
return out;
}
/** Everything on: when not even the policy can be read. */
/** Everything on, and each cap as low as it goes: when not even the policy can be read. */
export const ALL_DEVICE_LIMITS: ServerDevicePolicy = Object.fromEntries(
Object.keys(DEFAULT_ORGANISATION_POLICY.devices).map((key) => [key, true]),
Object.entries(DEFAULT_ORGANISATION_POLICY.devices).map(([key, off]) => [key, typeof off === 'number' ? 1 : true]),
) as ServerDevicePolicy;
/**
@@ -0,0 +1,146 @@
import { render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it } from 'vitest';
import type { BulwarkDesktopBridge } from '../types';
import { EMPTY_POLICY_LAYERS, resolvePolicies, type PolicyLayer, type PolicySnapshot } from '../policy-schema';
import { customServerAllowedByDesktopPolicy, desktopOfflinePolicy, desktopPolicyValue, jmapServerAllowedByDesktopPolicy, pluginAllowedByDesktopPolicy, pluginMayRunByDesktopPolicy, pluginUploadsAllowedByDesktopPolicy, useDesktopPolicy } from '../policy';
import { accountRefusedByDesktopPolicy, planForRefusedAccounts } from '../refused-accounts';
function snapshotOf(forcedMachine: PolicyLayer, recommendedMachine: PolicyLayer = {}): PolicySnapshot {
return resolvePolicies({ ...EMPTY_POLICY_LAYERS, forcedMachine, recommendedMachine });
}
let current: PolicySnapshot;
let listeners: ((snapshot: PolicySnapshot) => void)[];
function setBridgePolicy(snapshot: PolicySnapshot) {
current = snapshot;
listeners = [];
window.bulwarkDesktop = {
version: 1,
platform: 'win32',
mode: 'instance',
policy: {
get: () => current,
onChange: (listener) => {
listeners.push(listener);
return () => { listeners = listeners.filter((l) => l !== listener); };
},
},
} as BulwarkDesktopBridge;
}
afterEach(() => {
window.bulwarkDesktop = undefined;
});
describe('in a browser', () => {
it('nothing is refused and nothing is locked', () => {
expect(pluginAllowedByDesktopPolicy('anything')).toBe(true);
expect(jmapServerAllowedByDesktopPolicy('https://anything.example')).toBe(true);
expect(customServerAllowedByDesktopPolicy()).toBe(true);
expect(desktopOfflinePolicy()).toEqual({ forced: null, fallback: null });
});
it('a shell that predates the policy member counts as no policy', () => {
window.bulwarkDesktop = { version: 1, platform: 'win32' } as BulwarkDesktopBridge;
expect(pluginAllowedByDesktopPolicy('anything')).toBe(true);
});
});
describe('in the app', () => {
it('follows what the bridge says', () => {
setBridgePolicy(snapshotOf({ PluginsMode: 'allowlist', PluginAllowlist: ['good'], AllowedJmapServers: ['https://mail.example.com'] }));
expect(pluginAllowedByDesktopPolicy('good')).toBe(true);
expect(pluginAllowedByDesktopPolicy('bad')).toBe(false);
expect(jmapServerAllowedByDesktopPolicy('https://mail.example.com/x')).toBe(true);
expect(jmapServerAllowedByDesktopPolicy('https://other.example')).toBe(false);
});
it('with a list, a package of the user\'s own can\'t pass by naming a listed id, and nobody can add one', () => {
setBridgePolicy(snapshotOf({ PluginsMode: 'allowlist', PluginAllowlist: ['good'] }));
expect(pluginMayRunByDesktopPolicy({ id: 'good', managed: true })).toBe(true);
expect(pluginMayRunByDesktopPolicy({ id: 'good' })).toBe(false);
expect(pluginMayRunByDesktopPolicy({ id: 'bad', managed: true })).toBe(false);
expect(pluginUploadsAllowedByDesktopPolicy()).toBe(false);
setBridgePolicy(snapshotOf({ PluginsMode: 'off' }));
expect(pluginUploadsAllowedByDesktopPolicy()).toBe(false);
setBridgePolicy(snapshotOf({}));
expect(pluginMayRunByDesktopPolicy({ id: 'anything' })).toBe(true);
expect(pluginUploadsAllowedByDesktopPolicy()).toBe(true);
});
it('plans what happens to accounts on a server taken off the list', () => {
const accounts = [
{ id: 'a', serverUrl: 'https://off.example', isConnected: true },
{ id: 'b', serverUrl: 'https://on.example', isConnected: false },
{ id: 'c', serverUrl: 'https://on.example', isConnected: true },
];
const list = snapshotOf({ AllowedJmapServers: ['https://on.example'] });
expect(accountRefusedByDesktopPolicy(accounts[0], list)).toBe(true);
expect(accountRefusedByDesktopPolicy(accounts[1], list)).toBe(false);
expect(planForRefusedAccounts(accounts, 'a', list)).toEqual({ refused: ['a'], stop: ['a'], activeRefused: true, next: 'c' });
expect(planForRefusedAccounts(accounts, 'b', list)).toEqual({ refused: ['a'], stop: ['a'], activeRefused: false, next: null });
expect(planForRefusedAccounts([accounts[0]], 'a', list)).toEqual({ refused: ['a'], stop: ['a'], activeRefused: true, next: null });
expect(planForRefusedAccounts(accounts, 'a', snapshotOf({})).refused).toEqual([]);
});
it('a forced server hides the field for one of your own, unless direct connections are left on', () => {
setBridgePolicy(snapshotOf({ InstanceUrl: 'https://mail.example.com' }));
expect(customServerAllowedByDesktopPolicy()).toBe(false);
setBridgePolicy(snapshotOf({ InstanceUrl: 'https://mail.example.com', AllowStandalone: 1 }));
expect(customServerAllowedByDesktopPolicy()).toBe(true);
setBridgePolicy(snapshotOf({ AllowStandalone: 0 }));
expect(customServerAllowedByDesktopPolicy()).toBe(false);
setBridgePolicy(snapshotOf({}, { InstanceUrl: 'https://mail.example.com' }));
expect(customServerAllowedByDesktopPolicy()).toBe(true);
// A recommendation can't open what a forced server closed.
setBridgePolicy(snapshotOf({ InstanceUrl: 'https://mail.example.com' }, { AllowStandalone: 1 }));
expect(customServerAllowedByDesktopPolicy()).toBe(false);
});
it('forced offline goes over the user, recommended under', () => {
setBridgePolicy(snapshotOf({ OfflineEnabled: 0 }));
expect(desktopOfflinePolicy()).toEqual({ forced: false, fallback: null });
expect(desktopPolicyValue('OfflineEnabled', true)).toBe(false);
setBridgePolicy(snapshotOf({}, { OfflineEnabled: 0 }));
expect(desktopOfflinePolicy()).toEqual({ forced: null, fallback: false });
expect(desktopPolicyValue('OfflineEnabled', true)).toBe(true);
expect(desktopPolicyValue('OfflineEnabled')).toBe(false);
});
it('forced off is the same as the organisation forbidding it, for every account', async () => {
setBridgePolicy(snapshotOf({ OfflineEnabled: 0 }));
const { devicesForAccount } = await import('@/lib/device-policy');
expect(devicesForAccount('some-account').noOffline).toBe(true);
setBridgePolicy(snapshotOf({}));
expect(devicesForAccount('some-account').noOffline).toBe(false);
}, 60_000);
it('the hook settles on a bridge that copies what it returns, as contextBridge does', async () => {
setBridgePolicy(snapshotOf({ PluginsMode: 'off' }));
window.bulwarkDesktop!.policy!.get = () => JSON.parse(JSON.stringify(current));
let renders = 0;
function Probe() {
renders += 1;
return <span>{useDesktopPolicy().PluginsMode.value}</span>;
}
render(<Probe />);
expect(screen.getByText('off')).toBeInTheDocument();
expect(renders).toBeLessThan(4);
});
it('the hook shows a change at once', async () => {
setBridgePolicy(snapshotOf({}));
function Probe() {
return <span>{useDesktopPolicy().PluginsMode.value}</span>;
}
render(<Probe />);
expect(screen.getByText('allowed')).toBeInTheDocument();
const { act } = await import('@testing-library/react');
act(() => {
current = snapshotOf({ PluginsMode: 'off' });
for (const listener of listeners) listener(current);
});
expect(screen.getByText('off')).toBeInTheDocument();
});
});
@@ -0,0 +1,238 @@
/**
* @vitest-environment node
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { NextRequest } from 'next/server';
import type { ServerPolicy } from '../policy-schema';
/**
* PluginsMode in the install path, on the server. The five ways a plugin gets
* installed or switched on, the serving points, and what tightening leaves alone.
* Every route is the real one; only storage and the session are stand-ins.
*/
vi.mock('@/lib/admin/session', () => ({
requireAdminAuth: async () => ({ payload: { role: 'admin' } }),
getClientIP: () => '127.0.0.1',
}));
vi.mock('@/lib/admin/audit', () => ({ auditLog: async () => {} }));
vi.mock('@/lib/auth/instance-details', () => ({ canSeeInstanceDetails: async () => true }));
vi.mock('@/lib/admin/third-party-mode', () => ({ isNoThirdPartyTrafficOn: async () => false }));
vi.mock('@/lib/admin/config-manager', () => ({
configManager: {
ensureLoaded: async () => {},
get: () => undefined,
getPolicy: () => ({ forceEnabledPlugins: [], features: {} }),
},
}));
const registry = vi.hoisted(() => ({
plugins: [] as Record<string, unknown>[],
saved: [] as string[],
decided: [] as string[],
updated: [] as unknown[],
}));
vi.mock('@/lib/admin/plugin-registry', () => ({
getPlugin: async (id: string) => registry.plugins.find((p) => p.id === id) ?? null,
getTheme: async () => null,
getPluginRegistry: async () => ({ plugins: registry.plugins }),
getThemeRegistry: async () => ({ themes: [] }),
savePlugin: vi.fn(async (plugin: { id: string }) => { registry.saved.push(plugin.id); }),
saveTheme: vi.fn(),
deletePlugin: vi.fn(),
updatePluginMeta: vi.fn(async (id: string, updates: unknown) => { registry.updated.push([id, updates]); return { id, ...(updates as object) }; }),
getPluginBundle: async () => 'code',
isBundleMismatch: () => false,
getPluginSignature: async () => null,
readHistoryEntry: async () => ({ entry: { id: 'x', version: '0.9.0' }, code: 'old' }),
themeCSSResolver: async () => () => null,
}));
vi.mock('@/lib/admin/plugin-dev', () => ({
listDevPlugins: async () => [],
getDevPlugin: async () => null,
readDevBundle: async () => '',
}));
vi.mock('@/lib/admin/plugin-signing', () => ({ signBytes: async () => 'sig' }));
vi.mock('@/lib/admin/plugin-approvals', async (original) => ({
...(await original<object>()),
decideApproval: vi.fn(async (pluginId: string) => { registry.decided.push(pluginId); return { pluginId, bundleHash: 'a'.repeat(64), status: 'approved' }; }),
}));
vi.mock('@/lib/admin/csp-frame-origins', async (original) => {
const real = await original<object>();
return { ...real, invalidateFrameOriginsCache: () => {} };
});
// Every address counts as public here, so only the policy can be what refuses one.
vi.mock('@/lib/security/url-guard', async (original) => ({
...(await original<object>()),
isPublicHttpUrl: async () => true,
}));
const jar = vi.hoisted(() => ({ value: '' }));
const deleted = vi.hoisted(() => [] as string[]);
vi.mock('next/headers', () => ({
cookies: async () => ({
get: () => (jar.value ? { value: jar.value } : undefined),
set: () => {},
delete: (name: string) => deleted.push(name),
getAll: () => [],
}),
}));
vi.mock('@/lib/auth/crypto', () => ({
encryptSession: () => 'x',
decryptSession: () => ({ serverUrl: 'https://other.example.org', username: 'a', password: 'b' }),
decryptPayload: () => null,
encryptPayload: () => 'x',
}));
const POLICY_KEY = Symbol.for('bulwark.desktop.serverPolicy');
function setPolicy(policy: Partial<ServerPolicy> | undefined) {
(globalThis as unknown as Record<symbol, unknown>)[POLICY_KEY] = policy && { pluginAllowlist: [], allowedJmapServers: null, ...policy };
}
function jsonRequest(url: string, method: string, body: unknown) {
return new NextRequest(`http://localhost${url}`, {
method,
body: JSON.stringify(body),
headers: { 'content-type': 'application/json', 'sec-fetch-site': 'same-origin' },
});
}
beforeEach(() => {
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
vi.resetModules();
registry.plugins = [{ id: 'kept', name: 'Kept', version: '1.0.0', enabled: true, frameOrigins: ['https://kept.example'] }, { id: 'other', name: 'Other', version: '1.0.0', enabled: true, frameOrigins: ['https://other.example'] }];
registry.saved = [];
registry.decided = [];
registry.updated = [];
// A route that gets past the gate tries the directory. It must not reach a network.
vi.stubGlobal('fetch', vi.fn(async () => new Response('nope', { status: 502 })));
});
afterEach(() => {
setPolicy(undefined);
vi.unstubAllEnvs();
vi.unstubAllGlobals();
});
describe.each(['off', 'allowlist'] as const)('with PluginsMode %s and the plugin not on the list', (pluginsMode) => {
beforeEach(() => setPolicy({ pluginsMode, pluginAllowlist: pluginsMode === 'allowlist' ? ['kept'] : [] }));
it('the marketplace refuses before anyone asks the directory', async () => {
const { POST } = await import('@/app/api/admin/marketplace/route');
const answer = await POST(jsonRequest('/api/admin/marketplace', 'POST', { slug: 'other', version: '1.0.0', type: 'plugin' }));
expect(answer.status).toBe(403);
expect((await answer.json()).code).toBe('blocked_by_policy');
expect(fetch).not.toHaveBeenCalled();
expect(registry.saved).toEqual([]);
});
it('PATCH will not switch it on, or force it on', async () => {
const { PATCH } = await import('@/app/api/admin/plugins/route');
for (const body of [{ id: 'other', enabled: true }, { id: 'other', forceEnabled: true }]) {
const answer = await PATCH(jsonRequest('/api/admin/plugins', 'PATCH', body));
expect(answer.status).toBe(403);
}
expect(registry.updated).toEqual([]);
});
it('PATCH can still switch it off', async () => {
const { PATCH } = await import('@/app/api/admin/plugins/route');
const answer = await PATCH(jsonRequest('/api/admin/plugins', 'PATCH', { id: 'other', enabled: false }));
expect(answer.status).toBe(200);
});
it('a rollback is refused', async () => {
const { POST } = await import('@/app/api/admin/extensions/[kind]/[id]/rollback/route');
const answer = await POST(jsonRequest('/x', 'POST', { version: '0.9.0' }), { params: Promise.resolve({ kind: 'plugin', id: 'other' }) });
expect(answer.status).toBe(403);
expect(registry.saved).toEqual([]);
});
it('approving a user\'s package is refused, and denying it is not', async () => {
const { POST } = await import('@/app/api/admin/plugin-approvals/route');
const hash = 'a'.repeat(64);
expect((await POST(jsonRequest('/x', 'POST', { pluginId: 'other', bundleHash: hash, decision: 'approved' }))).status).toBe(403);
expect(registry.decided).toEqual([]);
expect((await POST(jsonRequest('/x', 'POST', { pluginId: 'other', bundleHash: hash, decision: 'denied' }))).status).toBe(200);
});
it('the bundle is not served', async () => {
const { GET } = await import('@/app/api/admin/plugins/[id]/bundle/route');
const answer = await GET(new NextRequest('http://localhost/api/admin/plugins/other/bundle'), { params: Promise.resolve({ id: 'other' }) });
expect(answer.status).toBe(403);
});
it('its frame origins are not in the CSP', async () => {
delete (globalThis as unknown as Record<symbol, unknown>)[Symbol.for('bulwark.admin.cspFrameOrigins')];
const { getEnabledPluginFrameOrigins } = await import('@/lib/admin/csp-frame-origins');
const origins = await getEnabledPluginFrameOrigins();
expect(origins).not.toContain('https://other.example');
expect(origins.includes('https://kept.example')).toBe(pluginsMode === 'allowlist');
});
it('the list still names it, flagged as refused, so nothing counts it as removed', async () => {
const { GET } = await import('@/app/api/plugins/route');
const answer = await GET(new NextRequest('http://localhost/api/plugins'));
const { plugins } = await answer.json() as { plugins: { id: string; refused?: boolean }[] };
expect(plugins.map((p) => p.id).sort()).toEqual(['kept', 'other']);
expect(plugins.find((p) => p.id === 'other')!.refused).toBe(true);
expect(plugins.find((p) => p.id === 'kept')!.refused === true).toBe(pluginsMode === 'off');
});
});
describe('what is allowed goes through', () => {
it('an allowlisted plugin passes the gate', async () => {
setPolicy({ pluginsMode: 'allowlist', pluginAllowlist: ['kept'] });
const { PATCH } = await import('@/app/api/admin/plugins/route');
const patched = await PATCH(jsonRequest('/api/admin/plugins', 'PATCH', { id: 'kept', enabled: true }));
expect(await patched.json()).toMatchObject({ plugin: { id: 'kept', enabled: true } });
expect(patched.status).toBe(200);
const { GET } = await import('@/app/api/admin/plugins/[id]/bundle/route');
expect((await GET(new NextRequest('http://localhost/x'), { params: Promise.resolve({ id: 'kept' }) })).status).toBe(200);
});
it('a build without the desktop flag ignores all of it, even with a value on globalThis', async () => {
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '');
setPolicy({ pluginsMode: 'off' });
const { PATCH } = await import('@/app/api/admin/plugins/route');
expect((await PATCH(jsonRequest('/api/admin/plugins', 'PATCH', { id: 'other', enabled: true }))).status).toBe(200);
const { GET } = await import('@/app/api/plugins/route');
const { plugins } = await (await GET(new NextRequest('http://localhost/api/plugins'))).json() as { plugins: { refused?: boolean }[] };
expect(plugins.every((p) => !p.refused)).toBe(true);
});
});
describe('AllowedJmapServers on the sign-in routes', () => {
it('refuses a server off the list before contacting it', async () => {
setPolicy({ pluginsMode: 'allowed', allowedJmapServers: ['https://mail.example.com'] });
const { POST } = await import('@/app/api/auth/session/route');
const answer = await POST(jsonRequest('/api/auth/session', 'POST', { serverUrl: 'https://other.example.org', username: 'a', password: 'b' }));
expect(answer.status).toBe(403);
expect((await answer.json()).code).toBe('blocked_by_policy');
expect(fetch).not.toHaveBeenCalled();
});
it('a stored session on a refused server isn\'t restored, and the cookie stays', async () => {
setPolicy({ pluginsMode: 'allowed', allowedJmapServers: ['https://mail.example.com'] });
jar.value = 'cookie';
deleted.length = 0;
const { PUT } = await import('@/app/api/auth/session/route');
const answer = await PUT(new NextRequest('http://localhost/api/auth/session', {
method: 'PUT',
headers: { 'sec-fetch-site': 'same-origin', 'sec-fetch-mode': 'cors', 'sec-fetch-dest': 'empty' },
}));
expect(answer.status).toBe(403);
expect((await answer.json()).code).toBe('blocked_by_policy');
expect(deleted).toEqual([]);
});
it('the browser sign-in\'s lookup refuses it too', async () => {
setPolicy({ pluginsMode: 'allowed', allowedJmapServers: ['https://mail.example.com'] });
const { resolveCustomUpstream } = await import('@/lib/oauth/custom-upstream');
expect(await resolveCustomUpstream('https://other.example.org', 'client')).toBeNull();
expect(await resolveCustomUpstream('https://mail.example.com', 'client')).toMatchObject({ serverUrl: 'https://mail.example.com' });
});
});
+353
View File
@@ -0,0 +1,353 @@
import { readFileSync } from 'node:fs';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import {
DEFAULT_POLICY_SNAPSHOT,
EMPTY_POLICY_LAYERS,
POLICY_KEYS,
POLICY_CONSUMERS,
POLICY_MAX_LIST_ITEMS,
POLICY_SCHEMA,
certificateMatchesRule,
certificateRuleMatchesUrl,
effectivePolicyValue,
jmapServerAllowed,
parseCertificateRule,
parseInstanceUrl,
parsePolicyValue,
parseServerPattern,
pluginAllowedByPolicy,
resolvePolicies,
serverPolicyOf,
type PolicyLayers,
} from '../policy-schema';
const layers = (partial: Partial<PolicyLayers>): PolicyLayers => ({ ...EMPTY_POLICY_LAYERS, ...partial });
describe('the schema', () => {
it('has every key the build asked for, with text in both languages', () => {
expect(POLICY_KEYS).toEqual([
'InstanceUrl', 'AllowStandalone', 'AllowedJmapServers', 'AllowInsecureInstance', 'UpdateChannel', 'AutoUpdate',
'PluginsMode', 'PluginAllowlist', 'OfflineEnabled', 'AppLockRequired', 'AppLockTimeoutMinutes', 'TelemetryAllowed',
'CrashReportsAllowed', 'QuickComposeShortcut', 'AuthServerAllowlist', 'AuthNegotiateDelegateAllowlist',
'AutoSelectCertificateForUrls', 'KeystoreAllowedOrigins', 'WipeOfflineData',
]);
for (const key of POLICY_KEYS) {
const { title, description } = POLICY_SCHEMA[key];
expect(title.en && title.de && description.en && description.de, key).toBeTruthy();
expect(description.en, key).not.toBe(description.de);
}
});
});
describe('precedence', () => {
it('forced machine > forced user > recommended machine > recommended user > default', () => {
const all = {
forcedMachine: { UpdateChannel: 'beta' },
forcedUser: { UpdateChannel: 'stable', AutoUpdate: 0 },
recommendedMachine: { UpdateChannel: 'stable', AutoUpdate: 1, AppLockTimeoutMinutes: 5 },
recommendedUser: { AppLockTimeoutMinutes: 9, QuickComposeShortcut: 'Ctrl+Alt+M' },
};
const snapshot = resolvePolicies(all);
expect(resolvePolicies(layers({ recommendedMachine: { PluginsMode: 'off' } })).PluginsMode).toMatchObject({ value: 'allowed', source: 'default' });
expect(snapshot.UpdateChannel).toMatchObject({ value: 'beta', source: 'forced-machine', locked: true });
expect(snapshot.AutoUpdate).toMatchObject({ value: false, source: 'forced-user', locked: true });
expect(snapshot.AppLockTimeoutMinutes).toMatchObject({ value: 5, source: 'recommended-machine', locked: false });
expect(snapshot.QuickComposeShortcut).toMatchObject({ value: 'Ctrl+Alt+M', source: 'recommended-user', locked: false });
expect(snapshot.PluginsMode).toMatchObject({ value: 'allowed', source: 'default', locked: false });
});
it('the user\'s choice sits under forced and over recommended', () => {
const snapshot = resolvePolicies(layers({ forcedUser: { AutoUpdate: 0 }, recommendedMachine: { OfflineEnabled: 0 } }));
expect(effectivePolicyValue(snapshot.AutoUpdate, true)).toBe(false);
expect(effectivePolicyValue(snapshot.OfflineEnabled, true)).toBe(true);
expect(effectivePolicyValue(snapshot.OfflineEnabled, null)).toBe(false);
expect(effectivePolicyValue(snapshot.TelemetryAllowed, undefined)).toBe(true);
});
it('takes a whole list from the strongest level and doesn\'t add them up', () => {
const snapshot = resolvePolicies(layers({
forcedMachine: { AllowedJmapServers: ['https://a.example'] },
forcedUser: { AllowedJmapServers: ['https://b.example'] },
}));
expect(snapshot.AllowedJmapServers.value).toEqual(['https://a.example']);
});
it('a wipe can only be forced', () => {
expect(resolvePolicies(layers({ recommendedMachine: { WipeOfflineData: 'x' } })).WipeOfflineData.value).toBeNull();
expect(resolvePolicies(layers({ forcedUser: { WipeOfflineData: 'x' } })).WipeOfflineData).toMatchObject({ value: 'x', locked: true });
});
});
describe('only a forced value counts for what decides what is allowed', () => {
it.each(['AllowStandalone', 'AllowedJmapServers', 'AllowInsecureInstance', 'PluginsMode', 'PluginAllowlist', 'AuthServerAllowlist', 'AuthNegotiateDelegateAllowlist', 'AutoSelectCertificateForUrls', 'KeystoreAllowedOrigins', 'WipeOfflineData'] as const)('%s', (key) => {
expect(POLICY_SCHEMA[key].forcedOnly).toBe(true);
});
it('a recommended AllowInsecureInstance can\'t let a forced http address through', () => {
const snapshot = resolvePolicies(layers({ forcedMachine: { InstanceUrl: 'http://bulwark.lan' }, recommendedMachine: { AllowInsecureInstance: 1 } }));
expect(snapshot.InstanceUrl).toMatchObject({ value: null, invalid: 'insecure' });
});
});
describe('hostile values fail closed', () => {
const junk: unknown[] = [{}, [], null, 'a'.repeat(100_000), Number.NaN, { toString: 'x' }, ['x'], -1];
it.each(POLICY_KEYS)('%s: junk never throws, and never comes out wider than it went in', (key) => {
for (const raw of junk) {
const snapshot = resolvePolicies(layers({ forcedMachine: { [key]: raw } }));
expect(snapshot[key].locked).toBe(true);
}
});
it('forced values that make no sense take the strict side, and a weaker level never speaks instead', () => {
const snapshot = resolvePolicies(layers({
forcedMachine: { AllowStandalone: 'maybe', PluginsMode: 'banana', OfflineEnabled: 7, TelemetryAllowed: 'yes', AppLockRequired: 'no', AllowedJmapServers: 'https://a.example', QuickComposeShortcut: 'rm -rf' },
recommendedMachine: { TelemetryAllowed: 1 },
}));
expect(snapshot.AllowStandalone).toMatchObject({ value: false, locked: true, invalid: 'not_a_switch' });
expect(snapshot.PluginsMode).toMatchObject({ value: 'off', locked: true, invalid: 'unknown_value' });
expect(snapshot.OfflineEnabled.value).toBe(false);
expect(snapshot.TelemetryAllowed.value).toBe(false);
expect(snapshot.AppLockRequired.value).toBe(true);
// A list that isn't one allows nothing, rather than everything.
expect(snapshot.AllowedJmapServers).toMatchObject({ value: [], invalid: 'not_a_list' });
expect(snapshot.QuickComposeShortcut.value).toBe('off');
});
it('a recommendation that makes no sense is simply not one', () => {
const snapshot = resolvePolicies(layers({ recommendedMachine: { UpdateChannel: 'banana' }, recommendedUser: { UpdateChannel: 'beta' } }));
expect(snapshot.UpdateChannel).toMatchObject({ value: 'beta', source: 'recommended-user', locked: false });
expect(snapshot.UpdateChannel.invalid).toBeUndefined();
});
it('a list over the cap is refused as a whole, and bad entries drop out one by one', () => {
const tooLong = Array.from({ length: POLICY_MAX_LIST_ITEMS + 1 }, (_, i) => `plugin-${i}`);
expect(resolvePolicies(layers({ forcedMachine: { PluginAllowlist: tooLong } })).PluginAllowlist).toMatchObject({ value: [], invalid: 'too_many' });
expect(resolvePolicies(layers({ forcedMachine: { PluginAllowlist: ['good', '../evil', 7, 'also-good'] } })).PluginAllowlist).toMatchObject({ value: ['good', 'also-good'], dropped: 2 });
});
it('keeps what was set for the error page, capped', () => {
const entry = resolvePolicies(layers({ forcedMachine: { InstanceUrl: `ftp://${'a'.repeat(500)}` } })).InstanceUrl;
expect(entry.invalid).toBe('scheme');
expect(entry.raw!.length).toBeLessThanOrEqual(201);
});
it('an out of range timeout falls to no value, not to a huge one', () => {
const entry = resolvePolicies(layers({ forcedMachine: { AppLockTimeoutMinutes: 999999 } })).AppLockTimeoutMinutes;
expect(entry).toMatchObject({ value: null, invalid: 'out_of_range' });
});
});
describe('InstanceUrl', () => {
it.each([
['https://mail.example.com', 'https://mail.example.com/'],
[' https://mail.example.com/ ', 'https://mail.example.com/'],
['https://Mail.Example.com:8443/bulwark//', 'https://mail.example.com:8443/bulwark/'],
])('%s is fine', (raw, value) => {
expect(parseInstanceUrl(raw, false)).toEqual({ ok: true, value });
});
it.each([
['http://mail.example.com', 'insecure'],
['file:///c:/x', 'scheme'],
['javascript:alert(1)', 'scheme'],
['data:text/html,x', 'scheme'],
['https://user:pw@mail.example.com', 'invalid_url'],
['https://mail.example.com/?x=1', 'invalid_url'],
['https://mail.example.com/#x', 'invalid_url'],
['not a url', 'invalid_url'],
['', 'empty'],
[`https://${'a'.repeat(3000)}.com`, 'too_long'],
['https://exa\nmple.com', 'invalid_url'],
['https://exa_mple.com', 'invalid_url'],
['https://exa"mple.com', 'invalid_url'],
[42, 'not_text'],
])('%s is refused', (raw, reason) => {
expect(parseInstanceUrl(raw, false)).toEqual({ ok: false, reason });
});
it('takes an international name as its punycode, and IP literals', () => {
expect(parseInstanceUrl('https://müller.de', false)).toEqual({ ok: true, value: 'https://xn--mller-kva.de/' });
expect(parseInstanceUrl('https://10.0.0.5:8443', false)).toEqual({ ok: true, value: 'https://10.0.0.5:8443/' });
expect(parseInstanceUrl('https://[::1]/', false)).toEqual({ ok: true, value: 'https://[::1]/' });
});
it('allows http only with AllowInsecureInstance, resolved from the policies', () => {
expect(resolvePolicies(layers({ forcedMachine: { InstanceUrl: 'http://bulwark.lan' } })).InstanceUrl).toMatchObject({ value: null, invalid: 'insecure' });
expect(resolvePolicies(layers({ forcedMachine: { InstanceUrl: 'http://bulwark.lan', AllowInsecureInstance: 1 } })).InstanceUrl).toMatchObject({ value: 'http://bulwark.lan/' });
expect(parseInstanceUrl('file:///x', true).ok).toBe(false);
});
});
describe('AllowedJmapServers', () => {
it.each([
['https://mail.example.com', 'https://mail.example.com'],
['mail.example.com', 'https://mail.example.com'],
['https://mail.example.com/', 'https://mail.example.com'],
['http://10.0.0.5:8080', 'http://10.0.0.5:8080'],
['https://*.example.com', 'https://*.example.com'],
['*.example.com', 'https://*.example.com'],
['HTTPS://Mail.Example.COM', 'https://mail.example.com'],
])('%s becomes %s', (raw, pattern) => {
expect(parseServerPattern(raw)).toBe(pattern);
});
it.each(['*', 'https://*', 'https://*.com', 'ftp://a.example', 'https://a.example/path', 'https://u:p@a.example', 'https://a.*.example', 'a b', '', 3])('%s is refused', (raw) => {
expect(parseServerPattern(raw)).toBeNull();
});
it('matches by scheme, host and port, and a wildcard only covers subdomains', () => {
const list = ['https://mail.example.com', 'https://*.corp.example'];
expect(jmapServerAllowed(list, 'https://mail.example.com/jmap/session')).toBe(true);
expect(jmapServerAllowed(list, 'http://mail.example.com')).toBe(false);
expect(jmapServerAllowed(list, 'https://mail.example.com:8443')).toBe(false);
expect(jmapServerAllowed(list, 'https://a.corp.example')).toBe(true);
expect(jmapServerAllowed(list, 'https://corp.example')).toBe(false);
expect(jmapServerAllowed(list, 'https://evilcorp.example')).toBe(false);
expect(jmapServerAllowed(list, 'https://mail.example.com.evil.test')).toBe(false);
expect(jmapServerAllowed(list, 'https://user:pw@mail.example.com')).toBe(false);
expect(jmapServerAllowed(list, 42)).toBe(false);
expect(jmapServerAllowed(list, 'nonsense')).toBe(false);
});
it('no list allows everything, an empty list nothing', () => {
expect(jmapServerAllowed(null, 'https://anything.example')).toBe(true);
expect(jmapServerAllowed([], 'https://anything.example')).toBe(false);
});
});
describe('plugins', () => {
it('allowed lets anything through, allowlist only what is listed, off nothing', () => {
expect(pluginAllowedByPolicy('allowed', [], 'x')).toBe(true);
expect(pluginAllowedByPolicy('allowlist', ['x'], 'x')).toBe(true);
expect(pluginAllowedByPolicy('allowlist', ['x'], 'X')).toBe(true);
expect(pluginAllowedByPolicy('allowlist', ['x'], 'y')).toBe(false);
expect(pluginAllowedByPolicy('allowlist', [], 'x')).toBe(false);
expect(pluginAllowedByPolicy('off', ['x'], 'x')).toBe(false);
expect(pluginAllowedByPolicy('allowlist', ['x'], undefined)).toBe(false);
});
});
describe('sign-in lists', () => {
it('passes names and wildcards through, without spaces', () => {
expect(parsePolicyValue('AuthServerAllowlist', '*.example.com, intranet')).toEqual({ ok: true, value: '*.example.com,intranet' });
});
it.each(['*', '*.example.com,*', '*.com', '*example.com', 'a.*.example.com', '*.', 'a b', '--no-sandbox x', 'a"b', 'a;b', ''])('%s is refused', (raw) => {
expect(parsePolicyValue('AuthServerAllowlist', raw).ok).toBe(false);
});
it('a forced refused list isn\'t applied at all', () => {
expect(resolvePolicies(layers({ forcedMachine: { AuthServerAllowlist: '*' } })).AuthServerAllowlist).toMatchObject({ value: null, invalid: 'invalid_host_list', locked: true });
});
});
describe('QuickComposeShortcut', () => {
it.each(['Ctrl+Alt+N', 'CommandOrControl+Shift+M', 'Alt+F4'])('%s is a shortcut', (raw) => {
expect(parsePolicyValue('QuickComposeShortcut', raw)).toEqual({ ok: true, value: raw });
});
it.each(['off', 'OFF', ''])('"%s" turns it off', (raw) => {
expect(parsePolicyValue('QuickComposeShortcut', raw)).toEqual({ ok: true, value: 'off' });
});
it.each(['N', 'Ctrl+', 'Ctrl+Alt+Banana', 'Ctrl+Alt', 7])('%s is refused', (raw) => {
expect(parsePolicyValue('QuickComposeShortcut', raw).ok).toBe(false);
});
});
describe('WipeOfflineData', () => {
it('takes text and numbers, and empty is nobody asking', () => {
expect(parsePolicyValue('WipeOfflineData', '2026-11-03')).toEqual({ ok: true, value: '2026-11-03' });
expect(parsePolicyValue('WipeOfflineData', 7)).toEqual({ ok: true, value: '7' });
expect(resolvePolicies(layers({ forcedMachine: { WipeOfflineData: ' ' } })).WipeOfflineData).toMatchObject({ value: null, locked: false });
expect(resolvePolicies(layers({ forcedMachine: { WipeOfflineData: 'x'.repeat(129) } })).WipeOfflineData.value).toBeNull();
});
});
describe('AutoSelectCertificateForUrls', () => {
const rule = (pattern: unknown, filter?: unknown) => JSON.stringify({ pattern, filter });
it('reads Chrome\'s format', () => {
expect(parseCertificateRule('{"pattern":"https://[*.]example.com","filter":{"ISSUER":{"CN":"Example CA"}}}')).toEqual({
host: 'example.com', subdomains: true, issuer: { CN: 'Example CA' },
});
expect(parseCertificateRule(rule('https://mail.example.com:8443', { SUBJECT: { O: 'Acme' } }))).toEqual({
host: 'mail.example.com:8443', subdomains: false, subject: { O: 'Acme' },
});
expect(parseCertificateRule(rule('example.com'))).toEqual({ host: 'example.com', subdomains: false });
});
it.each([
['not json'],
['[]'],
['null'],
[rule('*')],
[rule('https://*')],
[rule('http://example.com')],
[rule('https://example.com/path')],
[rule('https://exa mple.com')],
[rule(7)],
[rule('https://example.com', 'x')],
[rule('https://example.com', { ISSUER: 'x' })],
[rule('https://example.com', { ISSUER: { CN: 7 } })],
[JSON.stringify({ pattern: `https://${'a'.repeat(5000)}.com` })],
])('%s is refused', (raw) => {
expect(parseCertificateRule(raw)).toBeNull();
});
it('matches https only, and subdomains only when the rule says so', () => {
const wide = parseCertificateRule(rule('[*.]example.com'))!;
const exact = parseCertificateRule(rule('example.com'))!;
expect(certificateRuleMatchesUrl(wide, 'https://a.example.com/x')).toBe(true);
expect(certificateRuleMatchesUrl(wide, 'https://example.com')).toBe(true);
expect(certificateRuleMatchesUrl(exact, 'https://a.example.com')).toBe(false);
expect(certificateRuleMatchesUrl(wide, 'http://a.example.com')).toBe(false);
expect(certificateRuleMatchesUrl(wide, 'https://aexample.com')).toBe(false);
expect(certificateRuleMatchesUrl(wide, 'garbage')).toBe(false);
});
it('picks by issuer and subject', () => {
const parsed = parseCertificateRule(rule('example.com', { ISSUER: { CN: 'Ca', O: 'Org' }, SUBJECT: { OU: 'Unit' } }))!;
const good = { issuer: { commonName: 'Ca', organizations: ['Org'] }, subject: { organizationUnits: ['Unit'] } };
expect(certificateMatchesRule(parsed, good)).toBe(true);
expect(certificateMatchesRule(parsed, { ...good, issuer: { commonName: 'Other', organizations: ['Org'] } })).toBe(false);
expect(certificateMatchesRule(parsed, { ...good, subject: {} })).toBe(false);
expect(certificateMatchesRule(parseCertificateRule(rule('example.com'))!, {})).toBe(true);
});
});
describe('KeystoreAllowedOrigins', () => {
it('takes https origins and drops the rest', () => {
expect(resolvePolicies(layers({ forcedMachine: { KeystoreAllowedOrigins: ['https://mail.example.com/', 'http://mail.example.com', 'https://mail.example.com/path', 'nonsense'] } })).KeystoreAllowedOrigins).toMatchObject({ value: ['https://mail.example.com'], dropped: 3 });
});
});
describe('every key has someone acting on it', () => {
it('names a consumer or who owes one', () => {
for (const key of POLICY_KEYS) {
const consumer = POLICY_CONSUMERS[key];
expect(consumer, key).toBeTruthy();
if ('pendingConsumer' in consumer) expect(consumer.pendingConsumer.length, key).toBeGreaterThan(5);
}
});
it('a consumer in this repo really reads the key', () => {
for (const key of POLICY_KEYS) {
const consumer = POLICY_CONSUMERS[key];
if ('repo' in consumer && consumer.repo === 'webmail') expect(readFileSync(path.join(process.cwd(), consumer.file), 'utf8'), key).toContain(key);
}
});
// The coordinator flips this when step 7 is done: then no key may be waiting.
const ALL_WIRED = false;
it.runIf(ALL_WIRED)('nobody is waiting any more', () => {
expect(POLICY_KEYS.filter((key) => 'pendingConsumer' in POLICY_CONSUMERS[key])).toEqual([]);
});
});
describe('defaults', () => {
it('nothing set means nothing locked and nothing restricted', () => {
for (const key of POLICY_KEYS) expect(DEFAULT_POLICY_SNAPSHOT[key]).toMatchObject({ source: 'default', locked: false });
expect(serverPolicyOf(DEFAULT_POLICY_SNAPSHOT)).toEqual({ pluginsMode: 'allowed', pluginAllowlist: [], allowedJmapServers: null });
});
});
@@ -0,0 +1,203 @@
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import path from 'node:path';
import { describe, expect, it } from 'vitest';
import { POLICY_KEYS, POLICY_REGISTRY_ROOT, POLICY_SCHEMA, parsePolicyValue, resolvePolicies, type PolicyKey } from '../policy-schema';
import { generateAdml, generateAdmx, generateMobileconfig, generatePoliciesJson, generatePolicyFiles } from '../policy-templates';
const NS = 'http://schemas.microsoft.com/GroupPolicy/2006/07/PolicyDefinitions';
const OUT = path.join(process.cwd(), 'desktop-policies');
function parseXml(text: string): Document {
const doc = new DOMParser().parseFromString(text, 'application/xml');
const error = doc.querySelector('parsererror');
if (error) throw new Error(`not well-formed: ${error.textContent}`);
return doc;
}
function all(doc: Document | Element, name: string): Element[] {
return Array.from(doc.getElementsByTagNameNS(NS, name));
}
function direct(element: Element, name: string): Element[] {
return Array.from(element.children).filter((child) => child.localName === name);
}
describe('the committed templates', () => {
it('are what the schema generates', () => {
for (const [name, content] of Object.entries(generatePolicyFiles())) {
const file = path.join(OUT, name);
expect(existsSync(file), `${name} is missing: run node scripts/generate-desktop-policies.mjs`).toBe(true);
expect(readFileSync(file, 'utf8').replace(/\r\n/g, '\n'), `${name} differs: run node scripts/generate-desktop-policies.mjs`).toBe(content);
}
});
it('has no file the generator does not write', () => {
const found: string[] = [];
const walk = (dir: string, prefix: string) => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
if (entry.isDirectory()) walk(path.join(dir, entry.name), `${prefix}${entry.name}/`);
else found.push(`${prefix}${entry.name}`);
}
};
walk(OUT, '');
expect(found.sort()).toEqual(Object.keys(generatePolicyFiles()).sort());
});
});
describe('the desktop repo', () => {
// Only in a checkout with repos/desktop next to the code. CI's desktop repo has no webmail beside it.
const copy = path.join(process.cwd(), 'repos', 'desktop', 'src', 'shared', 'policy-schema.ts');
it.runIf(existsSync(copy))('holds the same schema: run node scripts/generate-desktop-policies.mjs --shell repos/desktop', () => {
const text = (file: string) => readFileSync(file, 'utf8').replace(/\r\n/g, '\n');
expect(text(copy)).toBe(text(path.join(process.cwd(), 'lib', 'desktop', 'policy-schema.ts')));
});
});
describe('the ADMX', () => {
const doc = parseXml(generateAdmx());
const root = doc.documentElement;
it('has the root, namespace and revisions Group Policy needs', () => {
expect(root.localName).toBe('policyDefinitions');
expect(root.namespaceURI).toBe(NS);
expect(root.getAttribute('revision')).toMatch(/^\d+\.\d+$/);
expect(root.getAttribute('schemaVersion')).toBe('1.0');
expect(Array.from(root.children).map((child) => child.localName)).toEqual(['policyNamespaces', 'resources', 'supportedOn', 'categories', 'policies']);
const target = all(doc, 'target')[0];
expect(target.getAttribute('prefix')).toBeTruthy();
expect(target.getAttribute('namespace')).toBe('Bulwark.Policies.Desktop');
expect(all(doc, 'resources')[0].getAttribute('minRequiredRevision')).toBe('1.0');
});
it('every policy has a real parent category and a supportedOn that exists', () => {
const categories = new Set(all(doc, 'category').map((c) => c.getAttribute('name')));
const supported = new Set(all(doc, 'definition').map((d) => d.getAttribute('name')));
expect(supported.size).toBe(1);
for (const category of all(doc, 'category')) {
for (const parent of direct(category, 'parentCategory')) expect(categories.has(parent.getAttribute('ref'))).toBe(true);
}
const policies = all(doc, 'policy');
expect(policies.length).toBeGreaterThan(POLICY_KEYS.length);
for (const policy of policies) {
const name = policy.getAttribute('name');
expect(categories.has(direct(policy, 'parentCategory')[0]?.getAttribute('ref')), `${name} category`).toBe(true);
expect(supported.has(direct(policy, 'supportedOn')[0]?.getAttribute('ref')), `${name} supportedOn`).toBe(true);
expect(policy.getAttribute('class')).toBe('Both');
expect(policy.getAttribute('key')?.startsWith(POLICY_REGISTRY_ROOT)).toBe(true);
}
});
it('keeps the children of a policy in the order the schema requires', () => {
const order = ['parentCategory', 'seeAlso', 'keywords', 'supportedOn', 'enabledValue', 'disabledValue', 'enabledList', 'disabledList', 'elements'];
for (const policy of all(doc, 'policy')) {
const names = Array.from(policy.children).map((child) => order.indexOf(child.localName));
expect(names, policy.getAttribute('name') ?? '').toEqual([...names].sort((a, b) => a - b));
expect(names).not.toContain(-1);
}
});
it('every key is there, forced and (except the wipe) as a default', () => {
const names = new Set(all(doc, 'policy').map((p) => p.getAttribute('name')));
for (const key of POLICY_KEYS) {
expect(names.has(key), key).toBe(true);
expect(names.has(`${key}_Recommended`), `${key}_Recommended`).toBe(!POLICY_SCHEMA[key].forcedOnly);
}
expect(names.has('WipeOfflineData_Recommended')).toBe(false);
});
it('puts list policies under their own subkey with numbered values', () => {
const lists = POLICY_KEYS.filter((key) => POLICY_SCHEMA[key].type === 'list');
expect(lists).toEqual(['AllowedJmapServers', 'PluginAllowlist', 'AutoSelectCertificateForUrls', 'KeystoreAllowedOrigins']);
for (const key of lists) {
const forced = all(doc, 'policy').find((p) => p.getAttribute('name') === key)!;
expect(forced.getAttribute('key')).toBe(`${POLICY_REGISTRY_ROOT}\\${key}`);
expect(forced.getAttribute('valueName')).toBeNull();
const list = all(forced, 'list')[0];
// Empty prefix is what makes Windows name the values 1, 2, 3.
expect(list.getAttribute('valuePrefix')).toBe('');
// Lists decide what's allowed, so they can only be forced.
expect(all(doc, 'policy').some((p) => p.getAttribute('name') === `${key}_Recommended`)).toBe(false);
}
});
it('writes booleans as 1 and 0 under the policy\'s value name', () => {
const policy = all(doc, 'policy').find((p) => p.getAttribute('name') === 'AllowStandalone')!;
expect(policy.getAttribute('valueName')).toBe('AllowStandalone');
expect(all(direct(policy, 'enabledValue')[0], 'decimal')[0].getAttribute('value')).toBe('1');
expect(all(direct(policy, 'disabledValue')[0], 'decimal')[0].getAttribute('value')).toBe('0');
});
it('writes the enum values the parser accepts', () => {
const policy = all(doc, 'policy').find((p) => p.getAttribute('name') === 'PluginsMode')!;
const values = all(policy, 'string').map((s) => s.textContent);
expect(values).toEqual(['allowed', 'allowlist', 'off']);
for (const value of values) expect(parsePolicyValue('PluginsMode', value).ok).toBe(true);
});
it.each(['en', 'de'] as const)('every reference resolves in the %s ADML', (language) => {
const adml = parseXml(generateAdml(language));
expect(adml.documentElement.localName).toBe('policyDefinitionResources');
expect(adml.documentElement.namespaceURI).toBe(NS);
const strings = new Set(all(adml, 'string').map((s) => s.getAttribute('id')));
const presentations = new Map(all(adml, 'presentation').map((p) => [p.getAttribute('id'), p]));
const text = generateAdmx();
const stringRefs = [...text.matchAll(/\$\(string\.([^)]+)\)/g)].map((m) => m[1]);
expect(stringRefs.length).toBeGreaterThan(40);
for (const id of stringRefs) expect(strings.has(id), id).toBe(true);
for (const policy of all(doc, 'policy')) {
const presentation = policy.getAttribute('presentation');
const elements = direct(policy, 'elements')[0];
if (!elements) {
expect(presentation).toBeNull();
continue;
}
const id = /^\$\(presentation\.(.+)\)$/.exec(presentation ?? '')?.[1];
expect(id, policy.getAttribute('name') ?? '').toBeTruthy();
const shown = presentations.get(id!);
expect(shown, id).toBeTruthy();
// Each input of the policy has a control that refers to it by id.
const refs = Array.from(shown!.children).map((child) => child.getAttribute('refId'));
expect(refs).toEqual(Array.from(elements.children).map((child) => child.getAttribute('id')));
}
});
it('the German text is German and every id is the same as in English', () => {
const ids = (language: 'en' | 'de') => all(parseXml(generateAdml(language)), 'string').map((s) => s.getAttribute('id'));
expect(ids('de')).toEqual(ids('en'));
expect(generateAdml('de')).toContain('Zugelassene Mailserver');
expect(generateAdml('en')).not.toContain('Zugelassene Mailserver');
});
});
describe('the samples', () => {
it('the policies.json parses, with nothing refused, and the wipe not set', () => {
const parsed = JSON.parse(generatePoliciesJson()) as Record<string, unknown> & { Recommended: Record<string, unknown> };
const { Recommended, ...forced } = parsed;
const snapshot = resolvePolicies({ forcedMachine: forced, forcedUser: {}, recommendedMachine: Recommended, recommendedUser: {} });
for (const key of POLICY_KEYS) {
expect(snapshot[key].invalid, key).toBeUndefined();
expect(snapshot[key].dropped, key).toBeUndefined();
}
expect(snapshot.InstanceUrl.value).toBe('https://mail.example.com/');
expect(snapshot.UpdateChannel.source).toBe('recommended-machine');
expect(generatePolicyFiles()['README.txt']).toContain('AppLocker');
expect(generateAdml('en')).not.toContain('1.0 and later');
expect(snapshot.WipeOfflineData.value).toBeNull();
});
it('the mobileconfig is a well-formed plist for the app\'s bundle id, with stable ids', () => {
const text = generateMobileconfig();
const doc = parseXml(text);
expect(doc.documentElement.localName).toBe('plist');
expect(text).toContain('<string>org.bulwarkmail.desktop</string>');
expect(text).not.toContain('WipeOfflineData');
const uuids = [...text.matchAll(/<key>PayloadUUID<\/key>\s*<string>([^<]+)<\/string>/g)].map((m) => m[1]);
expect(uuids).toHaveLength(2);
expect(new Set(uuids).size).toBe(2);
for (const uuid of uuids) expect(uuid).toMatch(/^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-8[0-9A-F]{3}-[0-9A-F]{12}$/);
expect(generateMobileconfig()).toBe(text);
const keys: PolicyKey[] = ['InstanceUrl', 'AllowedJmapServers', 'PluginsMode'];
for (const key of keys) expect(text).toContain(`<key>${key}</key>`);
});
});
+11
View File
@@ -0,0 +1,11 @@
import { getDesktopBridge } from './bridge';
/**
* Takes back what the user allowed in this session's certificate dialogs: the next
* account at this computer gets asked. A refusal stays (it lasts until the app quits),
* and so does a stored yes for an instance, which is about the instance and not the
* person. Nothing happens in a browser or on a shell without the call.
*/
export function clearKeystoreGrants(): void {
void Promise.resolve(getDesktopBridge()?.keystore?.clearGrants?.()).catch(() => {});
}
+846
View File
@@ -0,0 +1,846 @@
/**
* The desktop app's OS policies: every key, its type, its text in en and de, and
* how a raw value becomes a usable one. The ADMX and ADML templates, the sample
* .mobileconfig and the sample policies.json are generated from this file
* (scripts/generate-desktop-policies.mjs), so nobody writes those by hand.
*
* No DOM, no Node, no imports: the desktop repo keeps a byte-for-byte copy in
* src/shared/policy-schema.ts, the embedded server reads it, and so do pages.
*
* A policy value can be anything. A registry value may have the wrong type, a
* JSON file may hold an object where a string belongs, and a list may have a
* million entries. So every value goes through `parsePolicyValue`, which has caps
* and scheme checks, and a value it refuses fails closed: see `onInvalid`.
*/
export const POLICY_SCHEMA_VERSION = 1;
/** Under HKLM and HKCU. Only this tree is ever read: nothing a standard user can write. */
export const POLICY_REGISTRY_ROOT = 'Software\\Policies\\Bulwark\\Desktop';
export const POLICY_RECOMMENDED_SUBKEY = 'Recommended';
export const POLICY_BUNDLE_ID = 'org.bulwarkmail.desktop';
export const POLICY_LINUX_FILE = '/etc/bulwark/policies.json';
export const POLICY_MAX_STRING = 2048;
export const POLICY_MAX_NONCE = 128;
export const POLICY_MAX_LIST_ITEMS = 500;
export const POLICY_MAX_CERTIFICATE_RULE = 4096;
export const POLICY_MAX_FILE_BYTES = 1024 * 1024;
export type PolicyText = { en: string; de: string };
export type PolicyCategory = 'connection' | 'updates' | 'plugins' | 'security' | 'privacy' | 'signIn';
export const POLICY_CATEGORIES: Record<PolicyCategory, PolicyText> = {
connection: { en: 'Connection', de: 'Verbindung' },
updates: { en: 'Updates', de: 'Updates' },
plugins: { en: 'Plugins', de: 'Plugins' },
security: { en: 'Offline mail and app lock', de: 'Offline-E-Mails und App-Sperre' },
privacy: { en: 'Privacy', de: 'Datenschutz' },
signIn: { en: 'Sign-in and certificates', de: 'Anmeldung und Zertifikate' },
};
export interface CertificateName {
CN?: string;
L?: string;
O?: string;
OU?: string;
}
/** One entry of AutoSelectCertificateForUrls, in Chrome's format. */
export interface CertificateRule {
/** A lowercase host, optionally with a port. */
host: string;
/** Also matches subdomains: `[*.]example.com`. */
subdomains: boolean;
issuer?: CertificateName;
subject?: CertificateName;
}
export interface PolicyValues {
InstanceUrl: string | null;
AllowStandalone: boolean;
AllowedJmapServers: string[] | null;
AllowInsecureInstance: boolean;
UpdateChannel: 'stable' | 'beta';
AutoUpdate: boolean;
PluginsMode: 'allowed' | 'allowlist' | 'off';
PluginAllowlist: string[];
OfflineEnabled: boolean;
AppLockRequired: boolean;
AppLockTimeoutMinutes: number | null;
TelemetryAllowed: boolean;
CrashReportsAllowed: boolean;
QuickComposeShortcut: string | null;
AuthServerAllowlist: string | null;
AuthNegotiateDelegateAllowlist: string | null;
AutoSelectCertificateForUrls: CertificateRule[];
KeystoreAllowedOrigins: string[];
WipeOfflineData: string | null;
}
export type PolicyKey = keyof PolicyValues;
interface PolicyBase {
category: PolicyCategory;
title: PolicyText;
description: PolicyText;
/**
* Only a forced value counts. Anything that decides what's allowed: it has no user
* choice to sit under, so a "recommended" one would act as forced without being
* shown as such, and only a forced value may loosen a forced one.
*/
forcedOnly?: boolean;
}
export type PolicyDefinition = PolicyBase &
(
| {
type: 'boolean';
default: boolean;
/** What a forced value that makes no sense means. The strict side, never the open one. */
onInvalid: boolean;
/** What an example configuration sets. */
sample: boolean;
}
| {
type: 'integer';
default: number | null;
min: number;
max: number;
unit: PolicyText;
sample: number;
}
| {
type: 'enum';
default: string;
onInvalid: string;
values: readonly { value: string; title: PolicyText }[];
sample: string;
}
| {
type: 'string';
format: 'instanceUrl' | 'shortcut' | 'hostList' | 'nonce';
default: null;
maxLength: number;
label: PolicyText;
sample: string;
}
| {
type: 'list';
item: 'server' | 'plugin' | 'certificate' | 'origin';
default: readonly never[] | null;
label: PolicyText;
sample: readonly string[];
}
);
export const POLICY_SCHEMA: Record<PolicyKey, PolicyDefinition> = {
InstanceUrl: {
category: 'connection',
type: 'string',
format: 'instanceUrl',
default: null,
maxLength: POLICY_MAX_STRING,
sample: 'https://mail.example.com',
label: { en: 'Address of the Bulwark server', de: 'Adresse des Bulwark-Servers' },
title: { en: 'Bulwark server', de: 'Bulwark-Server' },
description: {
en: 'The address of your Bulwark server, starting with https://. When forced, the app opens on this server, the choice of server is hidden, and the window stays on it. An invalid address shows an error page that names this policy, and the app does not fall back to anything else. As a default, the app opens on this server until the user picks something else.',
de: 'Die Adresse Ihres Bulwark-Servers, beginnend mit https://. Wenn erzwungen, öffnet sich die App auf diesem Server, die Serverauswahl ist ausgeblendet und das Fenster bleibt auf diesem Server. Eine ungültige Adresse zeigt eine Fehlerseite mit dem Namen dieser Richtlinie, und die App weicht auf nichts anderes aus. Als Standardwert öffnet sich die App auf diesem Server, bis der Benutzer etwas anderes wählt.',
},
},
AllowStandalone: {
category: 'connection',
forcedOnly: true,
type: 'boolean',
default: true,
onInvalid: false,
sample: false,
title: { en: 'Allow direct connections to mail servers', de: 'Direkte Verbindungen zu Mailservern zulassen' },
description: {
en: 'When disabled, the app only opens a Bulwark server and nobody can sign in directly to a mail server. Accounts added earlier stay on the disk, but are hidden. If you force a Bulwark server and leave this not configured, direct connections are off too. Enable it to offer both.',
de: 'Wenn deaktiviert, öffnet die App nur einen Bulwark-Server, und niemand kann sich direkt bei einem Mailserver anmelden. Früher hinzugefügte Konten bleiben auf dem Datenträger, sind aber ausgeblendet. Wenn Sie einen Bulwark-Server erzwingen und diese Richtlinie nicht konfigurieren, sind direkte Verbindungen ebenfalls aus. Aktivieren Sie sie, um beides anzubieten.',
},
},
AllowedJmapServers: {
category: 'connection',
forcedOnly: true,
type: 'list',
item: 'server',
default: null,
sample: ['https://mail.example.com', 'https://*.mail.example.com'],
label: { en: 'Allowed mail servers', de: 'Zugelassene Mailserver' },
title: { en: 'Allowed mail servers', de: 'Zugelassene Mailserver' },
description: {
en: 'The mail servers people may sign in to. One per line: an address such as https://mail.example.com, or https://*.example.com for all subdomains. A server without a scheme means https. Plain http must be named with http://. When this list is set, everything else is refused, and accounts on a server taken off the list stop until it is back, keeping their data. A Bulwark server the user picks, or the one under "Bulwark server" as a default, must be on the list too. A forced one does not need to be.',
de: 'Die Mailserver, bei denen sich Benutzer anmelden dürfen. Einer pro Zeile: eine Adresse wie https://mail.example.com oder https://*.example.com für alle Subdomains. Ein Server ohne Schema bedeutet https. Unverschlüsseltes http muss mit http:// angegeben werden. Ist diese Liste gesetzt, wird alles andere abgelehnt, und Konten auf einem Server, der von der Liste genommen wird, ruhen, bis er wieder darauf steht, und behalten ihre Daten. Ein Bulwark-Server, den der Benutzer wählt, oder der unter „Bulwark-Server“ als Standardwert muss ebenfalls auf der Liste stehen. Ein erzwungener nicht.',
},
},
AllowInsecureInstance: {
category: 'connection',
forcedOnly: true,
type: 'boolean',
default: false,
onInvalid: false,
sample: false,
title: { en: 'Allow a Bulwark server on plain http', de: 'Bulwark-Server über unverschlüsseltes http zulassen' },
description: {
en: 'When enabled, the address in "Bulwark server" may start with http://. Passwords and mail then travel unencrypted, and the browser features that need https, such as offline mail and S/MIME, do not work. Leave this off unless the server is on a network you trust.',
de: 'Wenn aktiviert, darf die Adresse unter „Bulwark-Server“ mit http:// beginnen. Passwörter und E-Mails gehen dann unverschlüsselt über das Netzwerk, und Funktionen, die https brauchen, wie Offline-E-Mails und S/MIME, funktionieren nicht. Lassen Sie dies aus, sofern der Server nicht in einem Netzwerk steht, dem Sie vertrauen.',
},
},
UpdateChannel: {
category: 'updates',
type: 'enum',
default: 'stable',
onInvalid: 'stable',
sample: 'stable',
values: [
{ value: 'stable', title: { en: 'Stable', de: 'Stabil' } },
{ value: 'beta', title: { en: 'Beta', de: 'Beta' } },
],
title: { en: 'Update channel', de: 'Update-Kanal' },
description: {
en: 'Which releases the app updates to.',
de: 'Auf welche Versionen die App aktualisiert wird.',
},
},
AutoUpdate: {
category: 'updates',
type: 'boolean',
default: true,
onInvalid: false,
sample: false,
title: { en: 'Update automatically', de: 'Automatisch aktualisieren' },
description: {
en: 'When disabled, the app does not look for updates or download them. Use this when SCCM, Intune or similar tools deliver updates.',
de: 'Wenn deaktiviert, sucht die App nicht nach Updates und lädt keine herunter. Verwenden Sie dies, wenn SCCM, Intune oder ähnliche Werkzeuge die Updates liefern.',
},
},
PluginsMode: {
category: 'plugins',
forcedOnly: true,
type: 'enum',
default: 'allowed',
onInvalid: 'off',
sample: 'allowlist',
values: [
{ value: 'allowed', title: { en: 'All plugins are allowed', de: 'Alle Plugins sind erlaubt' } },
{ value: 'allowlist', title: { en: 'Only plugins on the list', de: 'Nur Plugins auf der Liste' } },
{ value: 'off', title: { en: 'No plugins', de: 'Keine Plugins' } },
],
title: { en: 'Which plugins may run', de: 'Welche Plugins laufen dürfen' },
description: {
en: 'Whether plugins can be installed and run. With the list or none, people cannot add a plugin themselves, since a package can name any id. With the list, only plugins your Bulwark server or the app\'s directory provides can run. A plugin that is refused keeps its data and settings: it comes back as it was if the policy allows it again.',
de: 'Ob Plugins installiert und ausgeführt werden dürfen. Mit der Liste oder ohne Plugins können Benutzer selbst keine Plugins hinzufügen, weil ein Paket jede beliebige ID nennen kann. Mit der Liste laufen nur Plugins, die Ihr Bulwark-Server oder das Verzeichnis der App bereitstellt. Ein abgelehntes Plugin behält seine Daten und Einstellungen: Es kommt unverändert zurück, wenn die Richtlinie es wieder erlaubt.',
},
},
PluginAllowlist: {
category: 'plugins',
forcedOnly: true,
type: 'list',
item: 'plugin',
default: [],
sample: ['example-plugin'],
label: { en: 'Plugin ids', de: 'Plugin-IDs' },
title: { en: 'Allowed plugins', de: 'Erlaubte Plugins' },
description: {
en: 'The ids of the plugins that may run when "Which plugins may run" is set to the list. One per line.',
de: 'Die IDs der Plugins, die laufen dürfen, wenn „Welche Plugins laufen dürfen“ auf die Liste gesetzt ist. Eine pro Zeile.',
},
},
OfflineEnabled: {
category: 'security',
type: 'boolean',
default: true,
onInvalid: false,
sample: true,
title: { en: 'Keep mail on the device', de: 'E-Mails auf dem Gerät behalten' },
description: {
en: 'Whether accounts keep an encrypted copy of mail on this device for offline use. When forced off, copies already on the device are removed.',
de: 'Ob Konten eine verschlüsselte Kopie der E-Mails auf diesem Gerät für die Offline-Nutzung behalten. Wenn erzwungen aus, werden bereits vorhandene Kopien entfernt.',
},
},
AppLockRequired: {
category: 'security',
type: 'boolean',
default: false,
onInvalid: true,
sample: true,
title: { en: 'Require an app lock', de: 'App-Sperre verlangen' },
description: {
en: 'When enabled, people must set up the app lock and cannot turn it off.',
de: 'Wenn aktiviert, müssen Benutzer die App-Sperre einrichten und können sie nicht ausschalten.',
},
},
AppLockTimeoutMinutes: {
category: 'security',
type: 'integer',
default: null,
min: 1,
max: 1440,
unit: { en: 'Minutes', de: 'Minuten' },
sample: 10,
title: { en: 'Lock the app after', de: 'App sperren nach' },
description: {
en: 'How many minutes without use the app stays unlocked. From 1 to 1440.',
de: 'Wie viele Minuten ohne Nutzung die App entsperrt bleibt. Von 1 bis 1440.',
},
},
TelemetryAllowed: {
category: 'privacy',
type: 'boolean',
default: true,
onInvalid: false,
sample: false,
title: { en: 'Allow usage statistics', de: 'Nutzungsstatistiken zulassen' },
description: {
en: 'When disabled, the app sends no usage statistics and does not ask. When enabled or not configured, it still asks the user first.',
de: 'Wenn deaktiviert, sendet die App keine Nutzungsstatistiken und fragt nicht danach. Wenn aktiviert oder nicht konfiguriert, fragt sie den Benutzer trotzdem zuerst.',
},
},
CrashReportsAllowed: {
category: 'privacy',
type: 'boolean',
default: true,
onInvalid: false,
sample: false,
title: { en: 'Allow crash reports', de: 'Absturzberichte zulassen' },
description: {
en: 'When disabled, the app sends no crash reports and does not ask. When enabled or not configured, it still asks the user first.',
de: 'Wenn deaktiviert, sendet die App keine Absturzberichte und fragt nicht danach. Wenn aktiviert oder nicht konfiguriert, fragt sie den Benutzer trotzdem zuerst.',
},
},
QuickComposeShortcut: {
category: 'security',
type: 'string',
format: 'shortcut',
default: null,
maxLength: 64,
sample: 'CommandOrControl+Alt+N',
label: { en: 'Shortcut', de: 'Tastenkombination' },
title: { en: 'Global shortcut for a new message', de: 'Globale Tastenkombination für neue Nachricht' },
description: {
en: 'The key combination that opens a new message from anywhere, such as CommandOrControl+Alt+N. Enter off to turn the shortcut off.',
de: 'Die Tastenkombination, die von überall aus eine neue Nachricht öffnet, etwa CommandOrControl+Alt+N. Geben Sie off ein, um sie auszuschalten.',
},
},
AuthServerAllowlist: {
category: 'signIn',
forcedOnly: true,
type: 'string',
format: 'hostList',
default: null,
maxLength: POLICY_MAX_STRING,
sample: '*.example.com',
label: { en: 'Servers', de: 'Server' },
title: { en: 'Servers for integrated sign-in', de: 'Server für die integrierte Anmeldung' },
description: {
en: 'The servers that may use the Windows sign-in (Kerberos, NTLM), separated by commas, such as *.example.com. Takes effect when the app restarts.',
de: 'Die Server, die die Windows-Anmeldung (Kerberos, NTLM) verwenden dürfen, durch Kommas getrennt, etwa *.example.com. Wird beim Neustart der App wirksam.',
},
},
AuthNegotiateDelegateAllowlist: {
category: 'signIn',
forcedOnly: true,
type: 'string',
format: 'hostList',
default: null,
maxLength: POLICY_MAX_STRING,
sample: '*.example.com',
label: { en: 'Servers', de: 'Server' },
title: { en: 'Servers for delegated sign-in', de: 'Server für die delegierte Anmeldung' },
description: {
en: 'The servers the app may delegate Kerberos credentials to, separated by commas. Takes effect when the app restarts.',
de: 'Die Server, an die die App Kerberos-Anmeldedaten delegieren darf, durch Kommas getrennt. Wird beim Neustart der App wirksam.',
},
},
AutoSelectCertificateForUrls: {
category: 'signIn',
forcedOnly: true,
type: 'list',
item: 'certificate',
default: [],
sample: ['{"pattern":"https://[*.]example.com","filter":{"ISSUER":{"CN":"Example CA"}}}'],
label: { en: 'Rules', de: 'Regeln' },
title: { en: 'Choose client certificates automatically', de: 'Clientzertifikate automatisch wählen' },
description: {
en: 'One JSON rule per line, in the format Chrome uses: {"pattern":"https://[*.]example.com","filter":{"ISSUER":{"CN":"Example CA"}}}. The app then picks the matching certificate for that site without asking.',
de: 'Eine JSON-Regel pro Zeile, im Format von Chrome: {"pattern":"https://[*.]example.com","filter":{"ISSUER":{"CN":"Example CA"}}}. Die App wählt dann für diese Website das passende Zertifikat ohne Rückfrage.',
},
},
KeystoreAllowedOrigins: {
category: 'signIn',
forcedOnly: true,
type: 'list',
item: 'origin',
default: [],
sample: ['https://mail.example.com'],
label: { en: 'Addresses', de: 'Adressen' },
title: { en: 'Sites that may use the system certificate store', de: 'Websites, die den Zertifikatspeicher des Systems verwenden dürfen' },
description: {
en: 'The Bulwark servers, by https address, whose plugins may sign and decrypt with the certificates in the operating system\'s store, without the user being asked for each site. One per line.',
de: 'Die Bulwark-Server, nach https-Adresse, deren Plugins mit den Zertifikaten im Speicher des Betriebssystems signieren und entschlüsseln dürfen, ohne dass der Benutzer für jede Website gefragt wird. Eine pro Zeile.',
},
},
WipeOfflineData: {
category: 'security',
type: 'string',
format: 'nonce',
default: null,
forcedOnly: true,
maxLength: POLICY_MAX_NONCE,
sample: '2026-11-03-1',
label: { en: 'Wipe code', de: 'Löschcode' },
title: { en: 'Delete mail kept on the device', de: 'Auf dem Gerät behaltene E-Mails löschen' },
description: {
en: 'Set this to a new value, such as today\'s date, to delete everything the app keeps on the device for every server and account: mail, keys, and sign-ins, so people sign in again. Each value deletes once: the app remembers the ones it has acted on, so it stays safe to leave a value in place. Use a new value to delete again.',
de: 'Setzen Sie dies auf einen neuen Wert, etwa das heutige Datum, um alles zu löschen, was die App für alle Server und Konten auf dem Gerät behält: E-Mails, Schlüssel und Anmeldungen, Benutzer melden sich also neu an. Jeder Wert löscht einmal: Die App merkt sich, welche sie schon ausgeführt hat, ein Wert kann also stehen bleiben. Verwenden Sie einen neuen Wert, um erneut zu löschen.',
},
},
};
export const POLICY_KEYS = Object.keys(POLICY_SCHEMA) as PolicyKey[];
export type PolicySource = 'forced-machine' | 'forced-user' | 'recommended-machine' | 'recommended-user' | 'default';
export interface PolicyEntry<K extends PolicyKey = PolicyKey> {
value: PolicyValues[K];
source: PolicySource;
/** Forced: the user can't change it, and the setting shows "Set by your organisation". */
locked: boolean;
/** A forced value we refused: `value` is then the strict one. Names the reason. */
invalid?: string;
/** Part of a list we dropped, while the rest stands. */
dropped?: number;
/** What was set, for an error page. Capped. */
raw?: string;
}
export type PolicySnapshot = { [K in PolicyKey]: PolicyEntry<K> };
/** One level of the registry, the profile or the file: raw values, untrusted. */
export type PolicyLayer = Partial<Record<PolicyKey, unknown>>;
export interface PolicyLayers {
forcedMachine: PolicyLayer;
forcedUser: PolicyLayer;
recommendedMachine: PolicyLayer;
recommendedUser: PolicyLayer;
}
export const EMPTY_POLICY_LAYERS: PolicyLayers = { forcedMachine: {}, forcedUser: {}, recommendedMachine: {}, recommendedUser: {} };
type Parsed<T> = { ok: true; value: T; dropped?: number } | { ok: false; reason: string };
// eslint-disable-next-line no-control-regex
const CONTROL = /[\u0000-\u001f\u007f]/;
function fail<T>(reason: string): Parsed<T> {
return { ok: false, reason };
}
/**
* A Bulwark server's address, normalised to `origin + path + /`. Only https, or http
* when the policy says so. No credentials, no query, no fragment.
*/
export function parseInstanceUrl(raw: unknown, allowInsecure: boolean): Parsed<string> {
if (typeof raw !== 'string') return fail('not_text');
const text = raw.trim();
if (!text) return fail('empty');
if (text.length > POLICY_MAX_STRING) return fail('too_long');
if (CONTROL.test(text)) return fail('invalid_url');
let url: URL;
try {
url = new URL(text);
} catch {
return fail('invalid_url');
}
if (url.protocol === 'http:') {
if (!allowInsecure) return fail('insecure');
} else if (url.protocol !== 'https:') {
return fail('scheme');
}
if (!url.hostname || url.username || url.password || url.search || url.hash) return fail('invalid_url');
// After the parser's work a real name is plain letters, digits, dots and hyphens (an
// international one becomes xn--), or an IP literal. Anything else is not an address.
if (!/^(?:[a-z0-9.-]+|\[[0-9a-f:.]+\])$/.test(url.hostname)) return fail('invalid_url');
return { ok: true, value: `${url.origin}${url.pathname.replace(/\/+$/, '')}/` };
}
/**
* One entry of AllowedJmapServers, normalised: `https://mail.example.com`,
* `http://10.0.0.5:8080`, or `https://*.example.com`.
*/
export function parseServerPattern(raw: unknown): string | null {
if (typeof raw !== 'string') return null;
let text = raw.trim();
if (!text || text.length > POLICY_MAX_STRING || CONTROL.test(text) || /\s/.test(text)) return null;
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(text)) text = `https://${text}`;
const wildcard = /^([a-z]+:\/\/)\*\.(.+)$/i.exec(text);
const candidate = wildcard ? `${wildcard[1]}${wildcard[2]}` : text;
if (candidate.includes('*')) return null;
let url: URL;
try {
url = new URL(candidate);
} catch {
return null;
}
if (url.protocol !== 'https:' && url.protocol !== 'http:') return null;
if (!url.hostname || url.username || url.password || url.search || url.hash) return null;
if (url.pathname !== '/' && url.pathname !== '') return null;
// A wildcard over a bare name would match the whole of a top-level domain.
if (wildcard && !url.hostname.includes('.')) return null;
return wildcard ? `${url.protocol}//*.${url.host}` : url.origin;
}
/** Whether a mail server's address is on the list. Null means no list, so everything is. */
export function jmapServerAllowed(patterns: readonly string[] | null | undefined, serverUrl: unknown): boolean {
if (patterns === null || patterns === undefined) return true;
if (typeof serverUrl !== 'string' || CONTROL.test(serverUrl)) return false;
let url: URL;
try {
url = new URL(serverUrl.trim());
} catch {
return false;
}
if (url.username || url.password) return false;
for (const pattern of patterns) {
const wildcard = /^([a-z]+:)\/\/\*\.(.+)$/.exec(pattern);
if (!wildcard) {
if (url.origin === pattern) return true;
continue;
}
const [, scheme, host] = wildcard;
if (url.protocol === scheme && url.host.endsWith(`.${host}`)) return true;
}
return false;
}
/** One entry of KeystoreAllowedOrigins: an https origin. */
export function parseHttpsOrigin(raw: unknown): string | null {
if (typeof raw !== 'string' || raw.length > POLICY_MAX_STRING || CONTROL.test(raw)) return null;
try {
const url = new URL(raw.trim());
if (url.protocol !== 'https:' || url.username || url.password || url.search || url.hash) return null;
if (url.pathname !== '/' || !/^[a-z0-9.-]+$/.test(url.hostname)) return null;
return url.origin;
} catch {
return null;
}
}
const PLUGIN_ID = /^[a-z0-9][a-z0-9._-]{0,127}$/;
export function parsePluginId(raw: unknown): string | null {
if (typeof raw !== 'string') return null;
const id = raw.trim().toLowerCase();
return PLUGIN_ID.test(id) ? id : null;
}
export type PluginsMode = PolicyValues['PluginsMode'];
/** The one question every plugin gate asks, in the server and in the page. */
export function pluginAllowedByPolicy(mode: PluginsMode, allowlist: readonly string[], id: unknown): boolean {
if (mode === 'allowed') return true;
if (mode !== 'allowlist' || typeof id !== 'string') return false;
return allowlist.includes(id.trim().toLowerCase());
}
const SHORTCUT = /^(?:(?:Command|Cmd|Control|Ctrl|CommandOrControl|CmdOrCtrl|Alt|Option|AltGr|Shift|Super|Meta)\+){1,4}(?:[A-Z0-9]|F[1-9]|F1[0-9]|F2[0-4]|Space|Tab|Enter|Backspace|Delete|Insert|Home|End|PageUp|PageDown|Up|Down|Left|Right|Plus|[-=,./;'`[\]\\])$/i;
function parseShortcut(raw: unknown): Parsed<string> {
if (typeof raw !== 'string') return fail('not_text');
const text = raw.trim();
if (text === '' || text.toLowerCase() === 'off') return { ok: true, value: 'off' };
if (text.length > 64 || !SHORTCUT.test(text)) return fail('invalid_shortcut');
return { ok: true, value: text };
}
const HOST_LIST = /^[A-Za-z0-9*.,:_[\]-]+$/;
/** What Chromium takes for its auth lists: names and wildcards, commas between them. */
function parseHostList(raw: unknown): Parsed<string> {
if (typeof raw !== 'string') return fail('not_text');
const text = raw.replace(/\s*,\s*/g, ',').trim();
if (!text) return fail('empty');
if (text.length > POLICY_MAX_STRING) return fail('too_long');
if (!HOST_LIST.test(text)) return fail('invalid_host_list');
const hosts = text.split(',').filter(Boolean);
if (hosts.length === 0 || hosts.length > POLICY_MAX_LIST_ITEMS) return fail('invalid_host_list');
// A wildcard must sit in front of a real domain of two labels or more: `*` or `*.com`
// would hand Windows credentials to any site that asks.
if (hosts.some((host) => host.includes('*') && !/^\*\.[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)+(?::\d+)?$/.test(host))) return fail('invalid_host_list');
return { ok: true, value: hosts.join(',') };
}
function parseNonce(raw: unknown): Parsed<string> {
const text = typeof raw === 'number' && Number.isFinite(raw) ? String(raw) : raw;
if (typeof text !== 'string') return fail('not_text');
const value = text.trim();
if (!value) return fail('empty');
if (value.length > POLICY_MAX_NONCE || CONTROL.test(value)) return fail('too_long');
return { ok: true, value };
}
function certificateName(raw: unknown): CertificateName | undefined | null {
if (raw === undefined) return undefined;
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return null;
const name: CertificateName = {};
for (const part of ['CN', 'L', 'O', 'OU'] as const) {
const value = (raw as Record<string, unknown>)[part];
if (value === undefined) continue;
if (typeof value !== 'string' || value.length > 256 || CONTROL.test(value)) return null;
name[part] = value;
}
return name;
}
/**
* Chrome's format: {"pattern":"https://[*.]example.com","filter":{"ISSUER":{"CN":"x"}}}.
* A rule must name a site: `*` would hand the certificate to any site that asks,
* without the user seeing it.
*/
export function parseCertificateRule(raw: unknown): CertificateRule | null {
if (typeof raw !== 'string' || raw.length > POLICY_MAX_CERTIFICATE_RULE) return null;
let rule: unknown;
try {
rule = JSON.parse(raw);
} catch {
return null;
}
if (typeof rule !== 'object' || rule === null || Array.isArray(rule)) return null;
const { pattern, filter } = rule as { pattern?: unknown; filter?: unknown };
if (typeof pattern !== 'string' || pattern.length > POLICY_MAX_STRING || CONTROL.test(pattern) || /\s/.test(pattern)) return null;
const match = /^(?:https:\/\/)?(\[\*\.\]|\*\.)?([^/*[\]]+(?::\d+)?)\/?$/i.exec(pattern);
if (!match) return null;
let host: string;
try {
host = new URL(`https://${match[2]}`).host;
} catch {
return null;
}
if (!host) return null;
if (filter !== undefined && (typeof filter !== 'object' || filter === null || Array.isArray(filter))) return null;
const { ISSUER, SUBJECT } = (filter ?? {}) as { ISSUER?: unknown; SUBJECT?: unknown };
const issuer = certificateName(ISSUER);
const subject = certificateName(SUBJECT);
if (issuer === null || subject === null) return null;
return { host, subdomains: Boolean(match[1]), ...(issuer ? { issuer } : {}), ...(subject ? { subject } : {}) };
}
/** Whether a rule covers this https address. */
export function certificateRuleMatchesUrl(rule: CertificateRule, url: string): boolean {
let parsed: URL;
try {
parsed = new URL(url);
} catch {
return false;
}
if (parsed.protocol !== 'https:') return false;
return parsed.host === rule.host || (rule.subdomains && parsed.host.endsWith(`.${rule.host}`));
}
/** What's needed from a certificate: Electron's `Certificate` fits. */
export interface CertificateFacts {
issuer?: { commonName?: string; locality?: string; organizations?: string[]; organizationUnits?: string[] };
subject?: { commonName?: string; locality?: string; organizations?: string[]; organizationUnits?: string[] };
}
function nameMatches(wanted: CertificateName | undefined, actual: CertificateFacts['issuer']): boolean {
if (!wanted) return true;
if (wanted.CN !== undefined && actual?.commonName !== wanted.CN) return false;
if (wanted.L !== undefined && actual?.locality !== wanted.L) return false;
if (wanted.O !== undefined && !actual?.organizations?.includes(wanted.O)) return false;
if (wanted.OU !== undefined && !actual?.organizationUnits?.includes(wanted.OU)) return false;
return true;
}
export function certificateMatchesRule(rule: CertificateRule, certificate: CertificateFacts): boolean {
return nameMatches(rule.issuer, certificate.issuer) && nameMatches(rule.subject, certificate.subject);
}
function parseBoolean(raw: unknown): Parsed<boolean> {
if (raw === true || raw === 1 || raw === '1') return { ok: true, value: true };
if (raw === false || raw === 0 || raw === '0') return { ok: true, value: false };
return fail('not_a_switch');
}
function parseInteger(raw: unknown, min: number, max: number): Parsed<number> {
const value = typeof raw === 'string' && /^\d{1,10}$/.test(raw.trim()) ? Number(raw.trim()) : raw;
if (typeof value !== 'number' || !Number.isInteger(value)) return fail('not_a_number');
if (value < min || value > max) return fail('out_of_range');
return { ok: true, value };
}
function parseList<T>(raw: unknown, item: (entry: unknown) => T | null): Parsed<T[]> {
if (!Array.isArray(raw)) return fail('not_a_list');
if (raw.length > POLICY_MAX_LIST_ITEMS) return fail('too_many');
const out: T[] = [];
let dropped = 0;
for (const entry of raw) {
const parsed = item(entry);
if (parsed === null) dropped += 1;
else out.push(parsed);
}
return dropped ? { ok: true, value: out, dropped } : { ok: true, value: out };
}
/**
* A raw value into what the app uses. The InstanceUrl needs to know whether http is
* allowed: pass the resolved AllowInsecureInstance.
*/
export function parsePolicyValue(key: PolicyKey, raw: unknown, allowInsecure = false): Parsed<PolicyValues[PolicyKey]> {
const definition = POLICY_SCHEMA[key];
switch (definition.type) {
case 'boolean':
return parseBoolean(raw);
case 'integer':
return parseInteger(raw, definition.min, definition.max);
case 'enum': {
const allowed = typeof raw === 'string' && definition.values.some((option) => option.value === raw.trim());
return allowed ? ({ ok: true, value: (raw as string).trim() } as Parsed<PolicyValues[PolicyKey]>) : fail('unknown_value');
}
case 'string':
switch (definition.format) {
case 'instanceUrl':
return parseInstanceUrl(raw, allowInsecure);
case 'shortcut':
return parseShortcut(raw);
case 'hostList':
return parseHostList(raw);
case 'nonce':
return parseNonce(raw);
}
break;
case 'list':
switch (definition.item) {
case 'server':
return parseList(raw, parseServerPattern);
case 'plugin':
return parseList(raw, parsePluginId);
case 'certificate':
return parseList(raw, parseCertificateRule);
case 'origin':
return parseList(raw, parseHttpsOrigin);
}
}
return fail('unknown_policy');
}
/** The strict side, for a forced value we can't read. Null where that's "not set". */
function failClosedValue(key: PolicyKey): PolicyValues[PolicyKey] {
const definition = POLICY_SCHEMA[key];
if (definition.type === 'boolean' || definition.type === 'enum') return definition.onInvalid as PolicyValues[PolicyKey];
// A list nobody can read allows nothing.
if (definition.type === 'list') return [] as PolicyValues[PolicyKey];
// A shortcut nobody can read doesn't get the built-in one instead.
if (definition.type === 'string' && definition.format === 'shortcut') return 'off' as PolicyValues[PolicyKey];
return definition.default as PolicyValues[PolicyKey];
}
function describeRaw(raw: unknown): string {
let text: string;
try {
text = typeof raw === 'string' ? raw : (JSON.stringify(raw) ?? String(raw));
} catch {
text = String(raw);
}
return text.length > 200 ? `${text.slice(0, 200)}…` : text;
}
const ORDER: { layer: keyof PolicyLayers; source: PolicySource; locked: boolean }[] = [
{ layer: 'forcedMachine', source: 'forced-machine', locked: true },
{ layer: 'forcedUser', source: 'forced-user', locked: true },
{ layer: 'recommendedMachine', source: 'recommended-machine', locked: false },
{ layer: 'recommendedUser', source: 'recommended-user', locked: false },
];
function resolveEntry<K extends PolicyKey>(key: K, layers: PolicyLayers, allowInsecure: boolean): PolicyEntry<K> {
const definition = POLICY_SCHEMA[key];
for (const { layer, source, locked } of ORDER) {
if (!locked && definition.forcedOnly) continue;
if (!Object.prototype.hasOwnProperty.call(layers[layer], key)) continue;
const raw = layers[layer][key];
if (raw === undefined) continue;
const parsed = parsePolicyValue(key, raw, allowInsecure);
if (parsed.ok) {
const entry = { value: parsed.value as PolicyValues[K], source, locked } as PolicyEntry<K>;
if (parsed.dropped) entry.dropped = parsed.dropped;
return entry;
}
// A recommendation we can't read is no recommendation. A forced value we can't
// read still means the admin wanted something: it fails closed, and a weaker
// layer never gets to speak instead.
if (!locked) continue;
// An empty nonce is someone clearing it, not a broken value.
if (definition.type === 'string' && definition.format === 'nonce' && parsed.reason === 'empty') {
return { value: null as PolicyValues[K], source: 'default', locked: false } as PolicyEntry<K>;
}
return { value: failClosedValue(key) as PolicyValues[K], source, locked, invalid: parsed.reason, raw: describeRaw(raw) } as PolicyEntry<K>;
}
return { value: definition.default as PolicyValues[K], source: 'default', locked: false } as PolicyEntry<K>;
}
/** Forced machine > forced user > (the user's choice) > recommended machine > recommended user > default. */
export function resolvePolicies(layers: PolicyLayers): PolicySnapshot {
const insecure = resolveEntry('AllowInsecureInstance', layers, false).value;
const snapshot = {} as Record<PolicyKey, PolicyEntry>;
for (const key of POLICY_KEYS) snapshot[key] = resolveEntry(key, layers, insecure) as PolicyEntry;
return snapshot as PolicySnapshot;
}
/**
* The place of the user's own choice in the order: under anything forced, over any
* recommendation. `undefined` and `null` mean they haven't chosen.
*/
export function effectivePolicyValue<T>(entry: { value: T; locked: boolean }, choice: T | null | undefined): T {
if (entry.locked) return entry.value;
return choice === undefined || choice === null ? entry.value : choice;
}
export const DEFAULT_POLICY_SNAPSHOT: PolicySnapshot = resolvePolicies(EMPTY_POLICY_LAYERS);
/**
* Who acts on each key, so none can sit idle: the desktop repo's test and the webmail's
* read the named file for the key. `pendingConsumer` names who still has to wire one.
*/
export type PolicyConsumer = { repo: 'shell' | 'webmail'; file: string } | { pendingConsumer: string };
export const POLICY_CONSUMERS: Record<PolicyKey, PolicyConsumer> = {
InstanceUrl: { repo: 'shell', file: 'src/main/policy-start.ts' },
AllowStandalone: { repo: 'shell', file: 'src/main/policy-start.ts' },
AllowedJmapServers: { repo: 'shell', file: 'src/main/policy-start.ts' },
AllowInsecureInstance: { repo: 'shell', file: 'src/main/policy-start.ts' },
PluginsMode: { repo: 'webmail', file: 'lib/desktop/policy.ts' },
PluginAllowlist: { repo: 'webmail', file: 'lib/desktop/policy.ts' },
OfflineEnabled: { repo: 'webmail', file: 'lib/offline/scope.ts' },
WipeOfflineData: { repo: 'shell', file: 'src/main/features/policies.ts' },
AuthServerAllowlist: { repo: 'shell', file: 'src/main/features/policies.ts' },
AuthNegotiateDelegateAllowlist: { repo: 'shell', file: 'src/main/features/policies.ts' },
AutoUpdate: { pendingConsumer: 'feat/packaging: src/main/features/updater.ts' },
UpdateChannel: { pendingConsumer: 'feat/packaging: src/main/features/updater.ts' },
AppLockRequired: { pendingConsumer: 'step 7, app lock' },
AppLockTimeoutMinutes: { pendingConsumer: 'step 7, app lock' },
TelemetryAllowed: { pendingConsumer: 'step 7, telemetry consent' },
CrashReportsAllowed: { pendingConsumer: 'step 7, telemetry consent' },
QuickComposeShortcut: { pendingConsumer: 'step 7, quick compose' },
AutoSelectCertificateForUrls: { pendingConsumer: 'step 7, client certificates' },
KeystoreAllowedOrigins: { pendingConsumer: 'feat/native-helper: setKeystoreAllowedOrigins in src/main/features/keystore.ts' },
};
/** The part the embedded server enforces. Sent to it by message. */
export interface ServerPolicy {
pluginsMode: PluginsMode;
pluginAllowlist: string[];
allowedJmapServers: string[] | null;
}
export function serverPolicyOf(snapshot: PolicySnapshot): ServerPolicy {
return {
pluginsMode: snapshot.PluginsMode.value,
pluginAllowlist: snapshot.PluginAllowlist.value,
allowedJmapServers: snapshot.AllowedJmapServers.value,
};
}
+310
View File
@@ -0,0 +1,310 @@
/**
* Turns the policy schema into the files admins deploy: ADMX and ADML for Group
* Policy and Intune, a sample .mobileconfig for macOS, a sample policies.json for
* Linux. `scripts/generate-desktop-policies.mjs` writes them to desktop-policies/;
* a test fails when what's committed differs from what this produces.
*/
import {
POLICY_BUNDLE_ID,
POLICY_CATEGORIES,
POLICY_KEYS,
POLICY_LINUX_FILE,
POLICY_RECOMMENDED_SUBKEY,
POLICY_REGISTRY_ROOT,
POLICY_SCHEMA,
type PolicyCategory,
type PolicyDefinition,
type PolicyKey,
type PolicyText,
} from './policy-schema';
type Language = keyof PolicyText;
const NAMESPACE = 'http://schemas.microsoft.com/GroupPolicy/2006/07/PolicyDefinitions';
const SUPPORTED = 'SUPPORTED_Bulwark_Desktop';
const ROOT_CATEGORY = 'Bulwark';
const FORCED_CATEGORY = 'Bulwark_Desktop';
const RECOMMENDED_CATEGORY = 'Bulwark_Desktop_Recommended';
const CATEGORY_IDS = Object.keys(POLICY_CATEGORIES) as PolicyCategory[];
function xml(text: string): string {
return text.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;' })[c] as string);
}
function categoryId(category: PolicyCategory): string {
return `Bulwark_Desktop_${category}`;
}
/** A policy appears twice in the tree: forced, and as a default the user can change. */
function policyVariants(key: PolicyKey): { recommended: boolean; id: string }[] {
return POLICY_SCHEMA[key].forcedOnly
? [{ recommended: false, id: key }]
: [{ recommended: false, id: key }, { recommended: true, id: `${key}_Recommended` }];
}
function policyElement(key: PolicyKey, recommended: boolean, id: string): string {
const definition = POLICY_SCHEMA[key];
const root = recommended ? `${POLICY_REGISTRY_ROOT}\\${POLICY_RECOMMENDED_SUBKEY}` : POLICY_REGISTRY_ROOT;
const parent = recommended ? RECOMMENDED_CATEGORY : categoryId(definition.category);
const common = `class="Both" displayName="$(string.${key})" explainText="$(string.${key}_Explain)"`;
const head = ` <parentCategory ref="${parent}"/>\n <supportedOn ref="${SUPPORTED}"/>`;
switch (definition.type) {
case 'boolean':
return [
` <policy name="${id}" ${common} key="${xml(root)}" valueName="${key}">`,
head,
' <enabledValue><decimal value="1"/></enabledValue>',
' <disabledValue><decimal value="0"/></disabledValue>',
' </policy>',
].join('\n');
case 'integer':
return [
` <policy name="${id}" ${common} presentation="$(presentation.${id})" key="${xml(root)}">`,
head,
' <elements>',
` <decimal id="${id}_Value" valueName="${key}" required="true" minValue="${definition.min}" maxValue="${definition.max}"/>`,
' </elements>',
' </policy>',
].join('\n');
case 'enum':
return [
` <policy name="${id}" ${common} presentation="$(presentation.${id})" key="${xml(root)}">`,
head,
' <elements>',
` <enum id="${id}_Value" valueName="${key}" required="true">`,
...definition.values.map(
(option) => ` <item displayName="$(string.${key}_${option.value})"><value><string>${xml(option.value)}</string></value></item>`,
),
' </enum>',
' </elements>',
' </policy>',
].join('\n');
case 'string':
return [
` <policy name="${id}" ${common} presentation="$(presentation.${id})" key="${xml(root)}">`,
head,
' <elements>',
` <text id="${id}_Value" valueName="${key}" required="true" maxLength="${definition.maxLength}"/>`,
' </elements>',
' </policy>',
].join('\n');
case 'list':
// valuePrefix="" gives values named 1, 2, 3 in a subkey of the policy's name.
return [
` <policy name="${id}" ${common} presentation="$(presentation.${id})" key="${xml(`${root}\\${key}`)}">`,
head,
' <elements>',
` <list id="${id}_Value" valuePrefix=""/>`,
' </elements>',
' </policy>',
].join('\n');
}
}
export function generateAdmx(): string {
const categories = [
` <category name="${ROOT_CATEGORY}" displayName="$(string.Category_Bulwark)"/>`,
` <category name="${FORCED_CATEGORY}" displayName="$(string.Category_Desktop)"><parentCategory ref="${ROOT_CATEGORY}"/></category>`,
` <category name="${RECOMMENDED_CATEGORY}" displayName="$(string.Category_Recommended)"><parentCategory ref="${ROOT_CATEGORY}"/></category>`,
...CATEGORY_IDS.map(
(category) => ` <category name="${categoryId(category)}" displayName="$(string.Category_${category})"><parentCategory ref="${FORCED_CATEGORY}"/></category>`,
),
];
const policies = POLICY_KEYS.flatMap((key) => policyVariants(key).map(({ recommended, id }) => policyElement(key, recommended, id)));
return [
'<?xml version="1.0" encoding="utf-8"?>',
'<!-- Generated from lib/desktop/policy-schema.ts by scripts/generate-desktop-policies.mjs. Do not edit. -->',
`<policyDefinitions xmlns="${NAMESPACE}" revision="1.0" schemaVersion="1.0">`,
' <policyNamespaces>',
' <target prefix="bulwark" namespace="Bulwark.Policies.Desktop"/>',
' </policyNamespaces>',
' <resources minRequiredRevision="1.0"/>',
' <supportedOn>',
' <definitions>',
` <definition name="${SUPPORTED}" displayName="$(string.${SUPPORTED})"/>`,
' </definitions>',
' </supportedOn>',
' <categories>',
...categories,
' </categories>',
' <policies>',
...policies,
' </policies>',
'</policyDefinitions>',
'',
].join('\n');
}
const INTRO: PolicyText = {
en: 'Settings for the Bulwark desktop app.',
de: 'Einstellungen für die Bulwark-Desktop-App.',
};
const SUPPORTED_TEXT: PolicyText = { en: 'All versions of Bulwark Desktop', de: 'Alle Versionen von Bulwark Desktop' };
const RECOMMENDED_TEXT: PolicyText = { en: 'Bulwark Desktop: defaults users can change', de: 'Bulwark Desktop: Standardwerte, die Benutzer ändern können' };
function presentationFor(key: PolicyKey, id: string, language: Language): string | null {
const definition: PolicyDefinition = POLICY_SCHEMA[key];
const refId = `${id}_Value`;
switch (definition.type) {
case 'boolean':
return null;
case 'integer':
return ` <presentation id="${id}"><decimalTextBox refId="${refId}" defaultValue="${definition.sample}">${xml(definition.unit[language])}</decimalTextBox></presentation>`;
case 'enum':
return ` <presentation id="${id}"><dropdownList refId="${refId}" noSort="true" defaultItem="0">${xml(definition.title[language])}</dropdownList></presentation>`;
case 'string':
return ` <presentation id="${id}"><textBox refId="${refId}"><label>${xml(definition.label[language])}</label></textBox></presentation>`;
case 'list':
return ` <presentation id="${id}"><listBox refId="${refId}">${xml(definition.label[language])}</listBox></presentation>`;
}
}
export function generateAdml(language: Language): string {
const strings: [string, string][] = [
['Category_Bulwark', 'Bulwark'],
['Category_Desktop', 'Bulwark Desktop'],
['Category_Recommended', RECOMMENDED_TEXT[language]],
[SUPPORTED, SUPPORTED_TEXT[language]],
...CATEGORY_IDS.map((category): [string, string] => [`Category_${category}`, POLICY_CATEGORIES[category][language]]),
];
for (const key of POLICY_KEYS) {
const definition = POLICY_SCHEMA[key];
strings.push([key, definition.title[language]], [`${key}_Explain`, definition.description[language]]);
if (definition.type === 'enum') {
for (const option of definition.values) strings.push([`${key}_${option.value}`, option.title[language]]);
}
}
const presentations = POLICY_KEYS.flatMap((key) =>
policyVariants(key)
.map(({ id }) => presentationFor(key, id, language))
.filter((line): line is string => line !== null),
);
return [
'<?xml version="1.0" encoding="utf-8"?>',
'<!-- Generated from lib/desktop/policy-schema.ts by scripts/generate-desktop-policies.mjs. Do not edit. -->',
`<policyDefinitionResources xmlns="${NAMESPACE}" revision="1.0" schemaVersion="1.0">`,
' <displayName>Bulwark Desktop</displayName>',
` <description>${xml(INTRO[language])}</description>`,
' <resources>',
' <stringTable>',
...strings.map(([id, text]) => ` <string id="${xml(id)}">${xml(text)}</string>`),
' </stringTable>',
' <presentationTable>',
...presentations,
' </presentationTable>',
' </resources>',
'</policyDefinitionResources>',
'',
].join('\n');
}
/** A stable UUID, so generating twice gives the same file. Not a secret, only an identifier. */
function stableUuid(name: string): string {
let a = 0x811c9dc5;
let b = 0x01000193;
for (const char of `bulwark-policy:${name}`) {
const code = char.charCodeAt(0);
a = Math.imul(a ^ code, 16777619) >>> 0;
b = Math.imul(b + code, 2166136261) >>> 0;
}
const hex = (value: number) => value.toString(16).padStart(8, '0');
const all = `${hex(a)}${hex(b)}${hex(Math.imul(a, b) >>> 0)}${hex((a ^ b) >>> 0)}`;
return `${all.slice(0, 8)}-${all.slice(8, 12)}-4${all.slice(13, 16)}-8${all.slice(17, 20)}-${all.slice(20, 32)}`.toUpperCase();
}
function plistValue(value: unknown, indent: string): string {
if (typeof value === 'boolean') return `${indent}<${value}/>`;
if (typeof value === 'number') return `${indent}<integer>${value}</integer>`;
if (typeof value === 'string') return `${indent}<string>${xml(value)}</string>`;
if (Array.isArray(value)) {
return [`${indent}<array>`, ...value.map((item) => plistValue(item, `${indent} `)), `${indent}</array>`].join('\n');
}
const entries = Object.entries(value as Record<string, unknown>);
return [
`${indent}<dict>`,
...entries.flatMap(([key, item]) => [`${indent} <key>${xml(key)}</key>`, plistValue(item, `${indent} `)]),
`${indent}</dict>`,
].join('\n');
}
/** What the samples set. A sample that would delete mail when deployed as it is doesn't set the nonce. */
function sampleValues(): Record<string, unknown> {
const sample: Record<string, unknown> = {};
for (const key of POLICY_KEYS) {
if (key === 'WipeOfflineData') continue;
const definition = POLICY_SCHEMA[key];
sample[key] = definition.sample;
}
return sample;
}
export function generateMobileconfig(): string {
const { InstanceUrl, UpdateChannel, ...forced } = sampleValues();
const payload = {
PayloadType: POLICY_BUNDLE_ID,
PayloadVersion: 1,
PayloadIdentifier: `${POLICY_BUNDLE_ID}.policy.settings`,
PayloadUUID: stableUuid('settings'),
PayloadEnabled: true,
PayloadDisplayName: 'Bulwark settings',
InstanceUrl,
...forced,
// Not forced: a default the user can change.
Recommended: { UpdateChannel },
};
const profile = {
PayloadContent: [payload],
PayloadType: 'Configuration',
PayloadVersion: 1,
PayloadIdentifier: `${POLICY_BUNDLE_ID}.policy`,
PayloadUUID: stableUuid('profile'),
PayloadDisplayName: 'Bulwark Desktop',
PayloadDescription: 'A sample profile. Remove what you do not want to set, and change the rest.',
PayloadScope: 'System',
};
return [
'<?xml version="1.0" encoding="UTF-8"?>',
'<!-- Generated from lib/desktop/policy-schema.ts by scripts/generate-desktop-policies.mjs. Do not edit. -->',
'<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">',
'<plist version="1.0">',
plistValue(profile, ''),
'</plist>',
'',
].join('\n');
}
export function generatePoliciesJson(): string {
const { UpdateChannel, ...forced } = sampleValues();
return `${JSON.stringify({ ...forced, Recommended: { UpdateChannel } }, null, 2)}\n`;
}
/** Relative path under desktop-policies/ to its content. */
export function generatePolicyFiles(): Record<string, string> {
return {
'Bulwark.admx': generateAdmx(),
'en-US/Bulwark.adml': generateAdml('en'),
'de-DE/Bulwark.adml': generateAdml('de'),
[`${POLICY_BUNDLE_ID}.mobileconfig`]: generateMobileconfig(),
'policies.json': generatePoliciesJson(),
'README.txt': [
'Generated by scripts/generate-desktop-policies.mjs. Do not edit these files.',
'',
'Windows: copy Bulwark.admx to %SystemRoot%\\PolicyDefinitions, and the en-US and',
'de-DE folders next to it (or to the central store in SYSVOL). In Intune, import',
'Bulwark.admx and en-US\\Bulwark.adml.',
'',
`macOS: ${POLICY_BUNDLE_ID}.mobileconfig is a sample. Change it and deploy it by MDM.`,
'',
`Linux: policies.json is a sample for ${POLICY_LINUX_FILE}. It must be owned by root`,
'and writable only by root (mode 644). Otherwise the app refuses to start.',
'',
'Limits: these settings stop people from changing them inside the app. The app is',
'not signed yet, so someone who can write to its folder or run their own copy can',
'patch it. For a hard guarantee, also limit what may run with AppLocker or WDAC,',
'and install per machine with the MSI so standard users cannot write to the folder.',
'',
].join('\n'),
};
}
+110
View File
@@ -0,0 +1,110 @@
/**
* The OS policies as a page sees them, through the desktop app's `policy` bridge.
* In a browser, or under a shell that predates this, there's no policy: everything
* here says "allowed" and "not locked", so browsers behave as they always did.
*
* Features ask here rather than reading the bridge themselves.
*/
import { useSyncExternalStore } from 'react';
import { getDesktopBridge } from './bridge';
import type { DesktopPolicyBridge } from './types';
import {
DEFAULT_POLICY_SNAPSHOT,
effectivePolicyValue,
jmapServerAllowed,
pluginAllowedByPolicy,
type PolicyEntry,
type PolicyKey,
type PolicySnapshot,
} from './policy-schema';
/** A plugin the device's policy refuses, on a plugin's record and in the settings page. */
export const PLUGIN_POLICY_REFUSED_ERROR = 'device-policy';
let cached: { bridge: DesktopPolicyBridge; snapshot: PolicySnapshot } | null = null;
/**
* The same object until the policy changes. The bridge can't give that: contextBridge
* copies what it returns, so each `get()` is a new object, and useSyncExternalStore takes
* a new object for a change and renders forever.
*/
export function getDesktopPolicy(): PolicySnapshot {
const bridge = getDesktopBridge()?.policy;
if (!bridge?.get) return DEFAULT_POLICY_SNAPSHOT;
if (cached?.bridge !== bridge) {
const entry = { bridge, snapshot: bridge.get() };
cached = entry;
bridge.onChange?.((snapshot) => {
entry.snapshot = snapshot;
});
}
return cached.snapshot;
}
export function onDesktopPolicyChange(listener: (snapshot: PolicySnapshot) => void): () => void {
// First, so what the listener reads is already the new value.
getDesktopPolicy();
return getDesktopBridge()?.policy?.onChange?.(listener) ?? (() => {});
}
const serverSnapshot = () => DEFAULT_POLICY_SNAPSHOT;
/** The policies, live, for a component. */
export function useDesktopPolicy(): PolicySnapshot {
return useSyncExternalStore(onDesktopPolicyChange, getDesktopPolicy, serverSnapshot);
}
export function desktopPolicyEntry<K extends PolicyKey>(key: K): PolicyEntry<K> {
return getDesktopPolicy()[key];
}
/** Forced beats the user's choice, which beats a recommendation. See policy-schema.ts. */
export function desktopPolicyValue<K extends PolicyKey>(key: K, userChoice?: PolicySnapshot[K]['value'] | null): PolicySnapshot[K]['value'] {
return effectivePolicyValue(desktopPolicyEntry(key), userChoice) as PolicySnapshot[K]['value'];
}
export function pluginAllowedByDesktopPolicy(id: string, snapshot: PolicySnapshot = getDesktopPolicy()): boolean {
return pluginAllowedByPolicy(snapshot.PluginsMode.value, snapshot.PluginAllowlist.value, id);
}
/**
* Whether a plugin may run. A package names its own id, so with a list only what an
* admin deployed (`managed`) can match it: anyone could build a package called the
* same as a listed plugin.
*/
export function pluginMayRunByDesktopPolicy(plugin: { id: string; managed?: boolean }, snapshot: PolicySnapshot = getDesktopPolicy()): boolean {
if (snapshot.PluginsMode.value === 'allowed') return true;
return plugin.managed === true && pluginAllowedByDesktopPolicy(plugin.id, snapshot);
}
/** With a list or none, nobody adds a package of their own. Same reason. */
export function pluginUploadsAllowedByDesktopPolicy(snapshot: PolicySnapshot = getDesktopPolicy()): boolean {
return snapshot.PluginsMode.value === 'allowed';
}
export function jmapServerAllowedByDesktopPolicy(serverUrl: string, snapshot: PolicySnapshot = getDesktopPolicy()): boolean {
return jmapServerAllowed(snapshot.AllowedJmapServers.value, serverUrl);
}
/**
* Whether the login page may offer a server of the user's own. A forced Bulwark
* server decides that alone, unless the same policy leaves direct connections on.
* `AllowStandalone` off says the same.
*/
export function customServerAllowedByDesktopPolicy(snapshot: PolicySnapshot = getDesktopPolicy()): boolean {
const { AllowStandalone, InstanceUrl } = snapshot;
if (AllowStandalone.locked) return AllowStandalone.value;
return !InstanceUrl.locked;
}
/**
* Whether accounts may keep mail on this device: `forced` when the policy says so
* either way, `fallback` for the default where the user hasn't chosen. Forced off
* feeds `noOffline` (lib/device-policy.ts), which also clears what's there already.
*/
export function desktopOfflinePolicy(snapshot: PolicySnapshot = getDesktopPolicy()): { forced: boolean | null; fallback: boolean | null } {
const { OfflineEnabled } = snapshot;
if (OfflineEnabled.locked) return { forced: OfflineEnabled.value, fallback: null };
return { forced: null, fallback: OfflineEnabled.source === 'default' ? null : OfflineEnabled.value };
}
+39
View File
@@ -0,0 +1,39 @@
/**
* Which accounts the device's OS policy refuses, and what follows from it. Pure, so
* the auth store only has to carry it out: a server taken off AllowedJmapServers stops
* its accounts without signing them out.
*/
import { getDesktopPolicy, jmapServerAllowedByDesktopPolicy } from './policy';
import type { PolicySnapshot } from './policy-schema';
interface AccountLike {
id: string;
serverUrl: string;
isConnected?: boolean;
}
export function accountRefusedByDesktopPolicy(account: { serverUrl: string }, snapshot: PolicySnapshot = getDesktopPolicy()): boolean {
return !jmapServerAllowedByDesktopPolicy(account.serverUrl, snapshot);
}
export interface RefusedPlan {
refused: string[];
/** To stop now: the refused ones, in the order stored. */
stop: string[];
activeRefused: boolean;
/** Where the active account moves: a connected one first, as a sign-out does. */
next: string | null;
}
export function planForRefusedAccounts(accounts: readonly AccountLike[], activeId: string | null, snapshot: PolicySnapshot): RefusedPlan {
const refused = accounts.filter((account) => accountRefusedByDesktopPolicy(account, snapshot)).map((account) => account.id);
const allowed = accounts.filter((account) => !refused.includes(account.id));
const activeRefused = !!activeId && refused.includes(activeId);
return {
refused,
stop: refused,
activeRefused,
next: activeRefused ? (allowed.find((account) => account.isConnected) ?? allowed[0])?.id ?? null : null,
};
}
+42
View File
@@ -0,0 +1,42 @@
/**
* What OS policies say the embedded server has to enforce: which plugins may be
* installed, switched on or served, and which mail servers it may sign in to.
*
* The Electron main process sends this at start and on every change. It lives on
* globalThis, which all of Next's bundles share, like the list of trusted servers
* in trusted-servers.ts. Nothing in the renderer or the environment can write it.
*
* Only the desktop build has it. In every other build these all answer "allowed".
*/
import { NextResponse } from 'next/server';
import { IS_DESKTOP_BUILD } from './flag';
import { jmapServerAllowed, pluginAllowedByPolicy, type ServerPolicy } from './policy-schema';
const POLICY_KEY = Symbol.for('bulwark.desktop.serverPolicy');
function serverPolicy(): ServerPolicy | undefined {
if (!IS_DESKTOP_BUILD) return undefined;
return (globalThis as unknown as Record<symbol, ServerPolicy | undefined>)[POLICY_KEY];
}
export function pluginRefusedByDevicePolicy(id: unknown): boolean {
const policy = serverPolicy();
return !!policy && !pluginAllowedByPolicy(policy.pluginsMode, policy.pluginAllowlist, id);
}
/** A 403 with a code the install route's caller can show, or null when it's allowed. */
export function devicePolicyPluginRefusal(id: unknown): NextResponse | null {
if (!pluginRefusedByDevicePolicy(id)) return null;
return NextResponse.json({ error: 'Your organisation does not allow this plugin on this device', code: 'blocked_by_policy' }, { status: 403 });
}
export function mailServerRefusedByDevicePolicy(serverUrl: unknown): boolean {
const policy = serverPolicy();
return !!policy && !jmapServerAllowed(policy.allowedJmapServers, serverUrl);
}
export function devicePolicyServerRefusal(serverUrl: unknown): NextResponse | null {
if (!mailServerRefusedByDevicePolicy(serverUrl)) return null;
return NextResponse.json({ error: 'Your organisation does not allow this server', code: 'blocked_by_policy' }, { status: 403 });
}
+12 -1
View File
@@ -7,6 +7,8 @@
* `version`; adding a member doesn't.
*/
import type { PolicySnapshot } from './policy-schema';
export const DESKTOP_BRIDGE_VERSION = 1;
export type DesktopWindowType = 'main' | 'message' | 'composer' | 'settings';
@@ -58,7 +60,7 @@ export interface DesktopShellBridge {
export type DesktopServerPrepareResult =
| { ok: true; approved: boolean }
| { ok: false; reason: 'declined' | 'invalid' };
| { ok: false; reason: 'declined' | 'invalid' | 'policy' };
export interface DesktopServersBridge {
/**
@@ -180,6 +182,14 @@ export interface DesktopKeystoreBridge {
accessState: () => Promise<DesktopKeystoreAccessState>;
/** Takes back a stored yes and this session's grants. Only ever narrows. */
revokeAccess: () => Promise<void>;
/** Takes back this session's grants, for the next account. Absent on older shells. */
clearGrants?: () => Promise<void>;
}
export interface DesktopPolicyBridge {
/** What's in force now. Synchronous: a page can read it in its first render. */
get: () => PolicySnapshot;
onChange: (listener: (snapshot: PolicySnapshot) => void) => () => void;
}
export interface DesktopOsSettings {
@@ -310,6 +320,7 @@ export interface BulwarkDesktopBridge {
shell?: DesktopShellBridge;
lifecycle?: DesktopLifecycleBridge;
os?: DesktopOsBridge;
policy?: DesktopPolicyBridge;
servers?: DesktopServersBridge;
auth?: DesktopAuthBridge;
plugins?: DesktopPluginsBridge;
+12 -11
View File
@@ -26,8 +26,9 @@
* the export engine and previews read blobs too.
*/
import { DEFAULT_ORGANISATION_POLICY, type OrganisationPolicy } from '@/lib/admin/organisation-policy';
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';
@@ -37,8 +38,6 @@ export type DevicePolicy = OrganisationPolicy['devices'];
export const NO_DEVICE_LIMITS: DevicePolicy = DEFAULT_ORGANISATION_POLICY.devices;
const KEYS = Object.keys(NO_DEVICE_LIMITS) as (keyof DevicePolicy)[];
function devicesOf(policy: { organisation?: OrganisationPolicy } | null | undefined): DevicePolicy {
return { ...NO_DEVICE_LIMITS, ...(policy?.organisation?.devices ?? {}) };
}
@@ -83,7 +82,14 @@ export function devicesForAccount(subject: DeviceSubject): DevicePolicy {
const store = usePolicyStore.getState();
const accountSubject = subjectForAccount(accountId);
const policy = accountSubject === store.subject ? store.policy : store.resolveFor(accountSubject);
return devicesOf(policy);
const devices = devicesOf(policy);
// The desktop app's OS policy says the same thing as the organisation's switch.
return desktopOfflinePolicy().forced === false ? { ...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. */
@@ -99,14 +105,9 @@ export function signedInAccountIds(): string[] {
return [...ids];
}
/** Every switch that any signed-in account has on: for pages that aren't about one account. */
/** 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 {
const out: DevicePolicy = { ...devicesForAccount(null) };
for (const id of signedInAccountIds()) {
const devices = devicesForAccount(id);
for (const key of KEYS) if (devices[key]) out[key] = true;
}
return out;
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. */
+35 -8
View File
@@ -24,6 +24,7 @@ import { isTaskLikeObject, type ScannedCalendarObject } from "@/lib/calendar-com
import { DEFAULT_CALENDAR_COMPONENTS, mkCalendarCollection, newCalendarCollectionName } from "@/lib/webdav/calendar-collection";
import { sanitizeDisplayName, splitMailbox } from "@/lib/rfc5322-mailbox";
import { decodeFileNodeName } from "./filenode-name";
import { PartialAccountsError } from "./partial-accounts";
import { fileNodeKind, fromDraftFileNode, toDraftFileNodeCreate } from "./filenode-kind";
import { fileNodeDepth } from "./filenode-depth";
import { contactFromWire, contactToWire } from "./contact-wire";
@@ -7537,10 +7538,16 @@ export class JMAPClient implements IJMAPClient {
}
}
/**
* Throws PartialAccountsError when an account couldn't answer, with what the
* others gave, so a failure doesn't read as that account having no books.
*/
async getAllAddressBooks(): Promise<AddressBook[]> {
const failed: string[] = [];
let failure: unknown;
const allBooks: AddressBook[] = [];
const primaryId = this.getContactsAccountId();
try {
const allBooks: AddressBook[] = [];
const primaryId = this.getContactsAccountId();
const accountIds = this.getContactCapableAccountIds();
for (const accountId of accountIds) {
@@ -7563,17 +7570,23 @@ export class JMAPClient implements IJMAPClient {
isShared: !isPrimary,
}));
allBooks.push(...books);
} else if (response.methodResponses?.[0]?.[0] === "error") {
const refused = response.methodResponses[0][1];
throw new Error(refused?.description || refused?.type || "AddressBook/get failed");
}
} catch (error) {
console.error(`Failed to fetch address books for account ${accountId}:`, error);
failed.push(accountId);
failure ??= error;
}
}
return allBooks;
if (failed.length === 0) return allBooks;
} catch (error) {
console.error('Failed to fetch all address books:', error);
return this.getAddressBooks();
}
throw new PartialAccountsError(allBooks, failed, primaryId, failure);
}
async createAddressBook(name: string): Promise<AddressBook> {
@@ -8440,6 +8453,8 @@ export class JMAPClient implements IJMAPClient {
], this.contactUsing());
const queryResult = response.methodResponses?.[0];
// A refused call isn't an empty address book: the caller keeps what it has.
if (queryResult?.[0] === "error") throw new Error(queryResult[1]?.description || queryResult[1]?.type || "ContactCard/query failed");
if (queryResult?.[0] !== "ContactCard/query") break;
const ids: string[] = queryResult[1].ids || [];
@@ -8462,8 +8477,10 @@ export class JMAPClient implements IJMAPClient {
["ContactCard/get", { accountId, ids: chunk }, "g"],
], this.contactUsing());
if (response.methodResponses?.[0]?.[0] === "ContactCard/get") {
const list = (response.methodResponses[0][1].list || []) as ContactCard[];
const getResult = response.methodResponses?.[0];
if (getResult?.[0] === "error") throw new Error(getResult[1]?.description || getResult[1]?.type || "ContactCard/get failed");
if (getResult?.[0] === "ContactCard/get") {
const list = (getResult[1].list || []) as ContactCard[];
allContacts.push(...list.map(contactFromWire));
}
}
@@ -8483,10 +8500,17 @@ export class JMAPClient implements IJMAPClient {
}
}
/**
* Throws PartialAccountsError when an account couldn't answer, with what the
* others gave, so a failure doesn't read as that account having no cards.
*/
async getAllContacts(): Promise<ContactCard[]> {
const failed: string[] = [];
let failure: unknown;
let allContacts: ContactCard[] = [];
const primaryId = this.getContactsAccountId();
try {
const allContacts: ContactCard[] = [];
const primaryId = this.getContactsAccountId();
allContacts = [];
const accountIds = this.getContactCapableAccountIds();
for (const accountId of accountIds) {
@@ -8509,14 +8533,17 @@ export class JMAPClient implements IJMAPClient {
allContacts.push(...contacts);
} catch (error) {
console.error(`Failed to fetch contacts for account ${accountId}:`, error);
failed.push(accountId);
failure ??= error;
}
}
return allContacts;
if (failed.length === 0) return allContacts;
} catch (error) {
console.error('Failed to fetch all contacts:', error);
return this.getContacts();
}
throw new PartialAccountsError(allContacts, failed, primaryId, failure);
}
async getContact(contactId: string, accountId?: string): Promise<ContactCard | null> {
+27
View File
@@ -0,0 +1,27 @@
/**
* A listing across JMAP accounts where at least one account couldn't answer.
* It carries what the others gave. An account that failed isn't an account
* with nothing in it, so the caller keeps what it had for that one.
*/
export class PartialAccountsError<T> extends Error {
constructor(
readonly items: T[],
readonly failedAccountIds: string[],
readonly primaryAccountId: string,
cause: unknown,
) {
super(cause instanceof Error ? cause.message : 'An account could not answer');
this.name = 'PartialAccountsError';
}
}
/** By name: `instanceof` fails across Next's bundle copies. */
export function partialAccounts<T>(error: unknown): PartialAccountsError<T> | null {
return error instanceof Error && error.name === 'PartialAccountsError' ? error as PartialAccountsError<T> : null;
}
/** What to show after a partial listing: the answers, and what's held for the accounts that failed. */
export function withFailedAccountsKept<T extends { accountId?: string }>(partial: PartialAccountsError<T>, held: readonly T[]): T[] {
const failed = new Set(partial.failedAccountIds);
return [...partial.items, ...held.filter((item) => failed.has(item.accountId ?? partial.primaryAccountId))];
}
+9 -1
View File
@@ -18,6 +18,7 @@ import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-server
import { decryptPayload, encryptPayload } from '@/lib/auth/crypto';
import { getSignInCookieOptions } from '@/lib/oauth/cookie-config';
import { isPublicHttpUrl } from '@/lib/security/url-guard';
import { mailServerRefusedByDevicePolicy } from '@/lib/desktop/server-policy';
type CookieStore = Awaited<ReturnType<typeof cookies>>;
@@ -36,6 +37,11 @@ export function customUpstreamCookieName(slot: number): string {
/** Null when the address isn't one the sign-in routes would accept right now. */
export async function resolveCustomUpstream(serverUrl: unknown, clientId: unknown): Promise<CustomUpstream | null> {
if (mailServerRefusedByDevicePolicy(serverUrl)) return null;
return checkedUpstream(serverUrl, clientId);
}
async function checkedUpstream(serverUrl: unknown, clientId: unknown): Promise<CustomUpstream | null> {
if (typeof serverUrl !== 'string' || !serverUrl || typeof clientId !== 'string' || !clientId) return null;
await configManager.ensureLoaded();
const servers = parseJmapServers(configManager.get<unknown>('jmapServers', []));
@@ -63,7 +69,9 @@ export async function readCustomUpstream(cookieStore: CookieStore, slot: number)
} catch {
return null;
}
return payload ? resolveCustomUpstream(payload.s, payload.c) : null;
// Without the device's policy: a sign-out still has to revoke its token at a server
// that came off the list. A refresh checks the policy itself.
return payload ? checkedUpstream(payload.s, payload.c) : null;
}
export function clearCustomUpstream(cookieStore: CookieStore, slot: number): void {
@@ -43,7 +43,7 @@ beforeEach(async () => {
forgetLegacyMove();
localStorage.clear();
useOfflineStore.setState({ enabled: [], downServers: [], browserOffline: false, outbox: [], pendingChanges: 0, conflicts: [] });
usePolicyStore.setState({ loaded: true, subject: null, policy: { ...usePolicyStore.getState().policy, organisation: DEFAULT_ORGANISATION_POLICY } });
usePolicyStore.setState({ loaded: true, subject: { email: 'alice@example.org', groups: [] }, policy: { ...usePolicyStore.getState().policy, organisation: DEFAULT_ORGANISATION_POLICY } });
useAccountStore.setState({ accounts: [{ id: 'acc', serverUrl: SERVER, username: 'alice@example.org' } as never] });
vi.stubGlobal('fetch', vi.fn(async (url: string, init?: RequestInit) => {
if (init?.mode === 'no-cors') {
+1 -1
View File
@@ -67,7 +67,7 @@ beforeEach(async () => {
forgetOrphanCheck();
localStorage.clear();
useOfflineStore.setState({ enabled: [], outbox: [], pendingChanges: 0, conflicts: [], localDrafts: [], browserOffline: false, downServers: [], startedOffline: false });
usePolicyStore.setState({ loaded: true, subject: null, policy: { ...usePolicyStore.getState().policy, organisation: DEFAULT_ORGANISATION_POLICY } });
usePolicyStore.setState({ loaded: true, subject: { email: 'alice@example.org', groups: [] }, policy: { ...usePolicyStore.getState().policy, organisation: DEFAULT_ORGANISATION_POLICY } });
useAccountStore.setState({ accounts: [{ id: 'acc', label: 'alice', serverUrl: 'https://mail.example.org', username: 'alice@example.org' } as never] });
const client = fakeClient();
useAuthStore.setState({ client: client as never, activeAccountId: 'acc' });
@@ -0,0 +1,410 @@
import 'fake-indexeddb/auto';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
import { getOfflineHooks, setOfflineHooks } from '@/lib/jmap/offline-hooks';
import { setStoreBackend } from '@/lib/store/client/api';
import { FakeJmap } from '@/lib/store/__tests__/helpers/fake-jmap';
import { InProcessStore } from '@/lib/store/__tests__/helpers/in-process-store';
import { useAccountStore } from '@/stores/account-store';
import { useAuthStore } from '@/stores/auth-store';
import { usePolicyStore } from '@/stores/policy-store';
import {
chooseScope,
clearAccount,
disableOffline,
enableOffline,
enforcePolicy,
refreshQueueState,
refreshUsage,
registerOfflineHooks,
scopeForAccount,
setOfflineNotifier,
startOfflineMode,
unsentCount,
wipeEverything,
type OfflineNotice,
} from '../offline-mode';
import { clearEvicted, markEvicted, OFFLINE_HEALTH_KEY, useOfflineStore, writeEnabled, writeHealth } from '../state';
const NOW = Date.now();
const iso = (daysAgo: number) => new Date(NOW - daysAgo * 86_400_000).toISOString();
const notices: OfflineNotice[] = [];
let store: InProcessStore;
let server: FakeJmap;
function setDevices(devices: Partial<typeof DEFAULT_ORGANISATION_POLICY.devices>): void {
usePolicyStore.setState({
loaded: true, source: 'server', subject: { email: 'alice@example.org', groups: [] }, subjectPending: false,
policy: { ...usePolicyStore.getState().policy, organisation: { ...DEFAULT_ORGANISATION_POLICY, devices: { ...DEFAULT_ORGANISATION_POLICY.devices, ...devices } } },
} as never);
}
const ids = (account = 'acc') => store.dbs.get(account)!.all<{ id: string }>('SELECT id FROM email ORDER BY id').map((row) => row.id);
beforeEach(() => {
localStorage.clear();
server = new FakeJmap();
store = new InProcessStore(server);
setStoreBackend(store);
notices.length = 0;
setOfflineNotifier((notice) => notices.push(notice));
useOfflineStore.setState({ enabled: [], choices: {}, health: {}, outbox: [], conflicts: [], areaConflicts: [], usage: {}, storageFull: {}, progress: {}, persist: 'unknown', browserOffline: false, downServers: [], startedOffline: false });
useAccountStore.setState({ accounts: [{ id: 'acc', label: 'alice', serverUrl: 'https://mail.example.org', username: 'alice@example.org' } as never] });
useAuthStore.setState({ activeAccountId: 'acc', client: null, getClientForAccount: (() => null) as never, getAllConnectedClients: (() => new Map()) as never });
setDevices({});
});
afterEach(async () => {
delete (window as { bulwarkDesktop?: unknown }).bulwarkDesktop;
setOfflineHooks(null);
setOfflineNotifier(() => {});
await wipeEverything();
setStoreBackend(null);
});
describe('at page load, before the subject is known', () => {
const forbiddenExceptGroup = {
...usePolicyStore.getState().rawPolicy,
organisation: { devices: { noOffline: true } },
scopes: [{ id: 'allowed', label: 'Allowed', match: { groups: ['team@example.org'] }, policy: { organisation: { devices: { noOffline: false } } } }],
};
function loading(): void {
useOfflineStore.setState({ enabled: ['acc'] });
usePolicyStore.setState({ rawPolicy: forbiddenExceptGroup, loaded: true, source: 'server', partial: false, subjectPending: false, subject: null } as never);
usePolicyStore.setState({ policy: usePolicyStore.getState().resolveFor(null) } as never);
useAuthStore.setState({ activeAccountId: 'acc', isLoading: true, client: null });
}
it('Everyone forbidding it wipes nothing while a group scope may allow it', async () => {
loading();
enforcePolicy();
await new Promise((resolve) => setTimeout(resolve, 5));
expect(store.removed).toEqual([]);
expect(useOfflineStore.getState().enabled).toEqual(['acc']);
expect(notices.filter((n) => n.kind === 'forbidden')).toEqual([]);
});
it('with the subject in, the group decides', async () => {
loading();
usePolicyStore.getState().setPolicySubject({ email: 'alice@example.org', groups: ['team@example.org'] });
enforcePolicy();
await new Promise((resolve) => setTimeout(resolve, 5));
expect(useOfflineStore.getState().enabled).toEqual(['acc']);
usePolicyStore.getState().setPolicySubject({ email: 'bob@example.org', groups: [] });
enforcePolicy();
await vi.waitFor(() => expect(useOfflineStore.getState().enabled).toEqual([]));
});
it('with no client coming, Everyone decides', async () => {
loading();
useAuthStore.setState({ isLoading: false, client: null });
enforcePolicy();
await vi.waitFor(() => expect(useOfflineStore.getState().enabled).toEqual([]));
});
it('pushes no scope either: a wider one would cost a re-list on every reload', async () => {
usePolicyStore.setState({ rawPolicy: { ...forbiddenExceptGroup, organisation: {}, scopes: [] }, loaded: true, source: 'server', partial: false, subject: null } as never);
useOfflineStore.setState({ enabled: ['acc'] });
useAuthStore.setState({ activeAccountId: 'acc', isLoading: true, client: null });
const setScope = vi.spyOn(store, 'setScope');
enforcePolicy();
await new Promise((resolve) => setTimeout(resolve, 5));
expect(setScope).not.toHaveBeenCalled();
});
});
describe('the public part of the policy', () => {
it('is not the organisation\'s word: no scope goes out, and no desktop default', async () => {
useOfflineStore.setState({ enabled: ['acc'] });
usePolicyStore.setState({ partial: true } as never);
const setScope = vi.spyOn(store, 'setScope');
enforcePolicy();
await new Promise((resolve) => setTimeout(resolve, 5));
expect(setScope).not.toHaveBeenCalled();
useOfflineStore.setState({ enabled: [] });
(window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1 };
enforcePolicy();
await new Promise((resolve) => setTimeout(resolve, 5));
expect(useOfflineStore.getState().enabled).toEqual([]);
});
});
describe('an account dropped by the app', () => {
it('is judged by what applies to anyone, not by the groups of whoever is signed in', async () => {
const policy = {
...usePolicyStore.getState().rawPolicy,
organisation: { devices: { noOffline: true } },
scopes: [{ id: 'allowed', label: 'Allowed', match: { groups: ['team@example.org'] }, policy: { organisation: { devices: { noOffline: false } } } }],
};
usePolicyStore.setState({ rawPolicy: policy, loaded: true, source: 'server', partial: false, subjectPending: false, subject: null } as never);
useAuthStore.setState({ activeAccountId: 'other', isLoading: false, client: null });
useAccountStore.setState({ accounts: [{ id: 'other', label: 'other', serverUrl: 'https://mail.example.org', username: 'other@example.org' } as never] });
usePolicyStore.getState().setPolicySubject({ email: 'other@example.org', groups: ['team@example.org'] });
useOfflineStore.setState({ enabled: ['gone', 'other'] });
markEvicted('gone');
enforcePolicy();
await vi.waitFor(() => expect(store.removed).toContain('gone'));
// Whoever is here keeps theirs: they are in the group.
expect(store.removed).not.toContain('other');
expect(useOfflineStore.getState().enabled).toEqual(['other']);
clearEvicted('gone');
});
});
describe('a policy fetch', () => {
async function kept(): Promise<void> {
server.addEmail({ id: 'old', receivedAt: iso(100) });
server.addEmail({ id: 'new', receivedAt: iso(5) });
writeEnabled(['acc']);
useOfflineStore.setState({ choices: { acc: { on: true, days: null } } });
await store.setScope('acc', scopeForAccount('acc').scope);
await store.sync('acc');
expect(ids()).toEqual(['new', 'old']);
}
it('with a lower cap prunes what is kept, without fetching what stays', async () => {
await kept();
server.calls.length = 0;
setDevices({ offlineMaxDays: 30 });
enforcePolicy();
await vi.waitFor(() => expect(ids()).toEqual(['new']));
expect(server.calls.filter(([name]) => name === 'Email/query')).toEqual([]);
});
it('with the cap raised again starts a backfill', async () => {
await kept();
setDevices({ offlineMaxDays: 30 });
enforcePolicy();
await vi.waitFor(() => expect(ids()).toEqual(['new']));
setDevices({});
enforcePolicy();
await vi.waitFor(() => expect(ids()).toEqual(['new', 'old']));
});
it('with defaults only (no policy heard yet) changes nothing', async () => {
await kept();
usePolicyStore.setState({ source: 'fallback' } as never);
const setScope = vi.spyOn(store, 'setScope');
enforcePolicy();
await new Promise((resolve) => setTimeout(resolve, 5));
expect(setScope).not.toHaveBeenCalled();
});
});
describe('the user picking what to keep', () => {
it('goes to the store and prunes', async () => {
server.addEmail({ id: 'old', receivedAt: iso(100) });
server.addEmail({ id: 'new', receivedAt: iso(5) });
writeEnabled(['acc']);
useOfflineStore.setState({ choices: { acc: { on: true, days: null } } });
await store.setScope('acc', scopeForAccount('acc').scope);
await store.sync('acc');
chooseScope('acc', { days: 30 });
await vi.waitFor(() => expect(ids()).toEqual(['new']));
expect(JSON.parse(localStorage.getItem('bulwark-offline-scope')!).acc.days).toBe(30);
});
it('waits for a choice to settle: arrowing past a short window prunes nothing', async () => {
writeEnabled(['acc']);
useOfflineStore.setState({ choices: { acc: { on: true, days: null } } });
const setScope = vi.spyOn(store, 'setScope');
vi.useFakeTimers();
try {
chooseScope('acc', { days: 30 });
await vi.advanceTimersByTimeAsync(300);
chooseScope('acc', { days: 90 });
await vi.advanceTimersByTimeAsync(300);
chooseScope('acc', { days: 365 });
await vi.advanceTimersByTimeAsync(300);
expect(setScope).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1000);
} finally {
vi.useRealTimers();
}
expect(setScope).toHaveBeenCalledTimes(1);
expect(setScope.mock.calls[0][1].days).toBe(365);
});
it('starts from what is in force: someone from before the local store keeps the rest of what they had', () => {
writeEnabled(['acc']);
useOfflineStore.setState({ choices: {} });
chooseScope('acc', { attachments: false });
expect(useOfflineStore.getState().choices.acc).toMatchObject({ on: true, days: 30, attachments: false });
});
it('can\'t get past the cap', () => {
setDevices({ offlineMaxDays: 30 });
writeEnabled(['acc']);
chooseScope('acc', { days: null });
expect(scopeForAccount('acc').scope.days).toBe(30);
});
});
describe('opened messages', () => {
it('past the organisation\'s cap aren\'t recorded; under it they are', async () => {
registerOfflineHooks();
writeEnabled(['acc']);
setDevices({ offlineMaxDays: 30 });
const area = vi.spyOn(store, 'area');
const client = { getServerUrl: () => 'https://mail.example.org', getUsername: () => 'alice@example.org', getAccountId: () => 'j' } as never;
const message = (id: string, receivedAt: string) => ({ id, receivedAt, mailboxIds: { inbox: true }, bodyValues: { 1: { value: 'x', isTruncated: false } } });
getOfflineHooks()!.record!(client, [['Email/get', { accountId: 'j' }, 'c']], {
methodResponses: [['Email/get', { accountId: 'j', list: [message('old', iso(100)), message('new', iso(5))] }, 'c']],
} as never);
await vi.waitFor(() => expect(area).toHaveBeenCalled());
const recorded = area.mock.calls.filter(([, name]) => name === 'mail.recordOpened').flatMap(([, , params]) => (params as { emails: Array<{ id: string }> }).emails.map((email) => email.id));
expect(recorded).toEqual(['new']);
});
});
describe('the usage of several accounts', () => {
it('one store in trouble leaves what the others said', async () => {
writeEnabled(['acc', 'bad']);
const area = vi.spyOn(store, 'area');
const real = area.getMockImplementation();
area.mockImplementation(async (account: string, name: string) => {
if (name !== 'store.usage') return real ? real(account, name) : undefined;
if (account === 'bad') throw new Error('gone');
return { bytes: 5, parts: [] };
});
await refreshUsage();
expect(useOfflineStore.getState().usage.acc?.bytes).toBe(5);
area.mockRestore();
});
});
describe('what a browser clearing the store would lose', () => {
it('counts local drafts too', async () => {
writeEnabled(['acc']);
const real = store.area.bind(store);
vi.spyOn(store, 'area').mockImplementation(async (account: string, name: string, params?: unknown) => (
name.startsWith('drafts.') && !name.includes('Elsewhere')
? [{ localId: 'local-1', jmap: 'j', subject: 'Only here', status: 'waiting', createdAt: 0 }]
: real(account, name, params)
) as never);
await refreshQueueState();
expect(useOfflineStore.getState().health.acc?.pending).toBe(1);
vi.restoreAllMocks();
});
});
describe('what would be lost', () => {
it('counts changes per account when a store can\'t answer, and local drafts', async () => {
useOfflineStore.setState({
enabled: ['acc', 'b'],
changesBy: { acc: 2, b: 7 },
outbox: [],
localDrafts: [{ account: 'acc', localId: 'd', status: 'waiting' } as never],
});
vi.spyOn(store, 'area').mockRejectedValue(new Error('gone'));
expect(await unsentCount('acc')).toEqual({ messages: 1, changes: 2 });
vi.restoreAllMocks();
});
});
describe('turning the last account off', () => {
it('keeps the off of another account still signed in', async () => {
(window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1 };
useAuthStore.setState({ activeAccountId: 'acc', getAllConnectedClients: (() => new Map([['acc', {}], ['other', {}]])) as never });
useOfflineStore.setState({ choices: { other: { on: false } } });
writeEnabled(['acc']);
await disableOffline('acc');
expect(useOfflineStore.getState().choices).toEqual({ acc: { on: false }, other: { on: false } });
expect(JSON.parse(localStorage.getItem('bulwark-offline-scope')!).other.on).toBe(false);
});
});
describe('clearing an account', () => {
it('removes its store and starts over, and it stays on', async () => {
server.addEmail({ id: 'new', receivedAt: iso(5) });
writeEnabled(['acc']);
await store.sync('acc');
expect(ids()).toEqual(['new']);
const before = store.dbs.get('acc');
await clearAccount('acc');
expect(store.removed).toContain('acc');
expect(useOfflineStore.getState().enabled).toEqual(['acc']);
expect(store.dbs.get('acc')).not.toBe(before);
expect(useOfflineStore.getState().health.acc?.clearedAt).toBeGreaterThan(0);
expect(notices.filter((n) => n.kind === 'store-reset')).toEqual([]);
});
});
describe('clearing in another tab', () => {
it('opens the new store here, in case this tab owns it', async () => {
writeEnabled(['acc']);
writeHealth('acc', { clearedAt: 1 });
const stop = startOfflineMode();
const ensureOpen = vi.spyOn(store, 'ensureOpen');
ensureOpen.mockClear();
const area = vi.spyOn(store, 'area');
// What the start itself asks for is over.
await new Promise((resolve) => setTimeout(resolve, 20));
area.mockClear();
localStorage.setItem(OFFLINE_HEALTH_KEY, JSON.stringify({ acc: { clearedAt: 2, pending: 0 } }));
window.dispatchEvent(new StorageEvent('storage', { key: OFFLINE_HEALTH_KEY }));
expect(ensureOpen).toHaveBeenCalledWith('acc');
expect(useOfflineStore.getState().health.acc.clearedAt).toBe(2);
// Its lists and sizes are of a store that's gone.
await vi.waitFor(() => expect(area).toHaveBeenCalledWith('acc', 'store.usage'));
stop();
});
});
describe('the desktop app', () => {
const asDesktop = () => { (window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1 }; };
it('keeps mail for an account without asking, and says so once', async () => {
asDesktop();
enforcePolicy();
await vi.waitFor(() => expect(useOfflineStore.getState().enabled).toEqual(['acc']));
expect(notices.filter((n) => n.kind === 'default-on')).toHaveLength(1);
enforcePolicy();
expect(notices.filter((n) => n.kind === 'default-on')).toHaveLength(1);
});
it('not when the organisation forbids it', async () => {
asDesktop();
setDevices({ noOffline: true });
enforcePolicy();
await new Promise((resolve) => setTimeout(resolve, 5));
expect(useOfflineStore.getState().enabled).toEqual([]);
expect(notices.filter((n) => n.kind === 'default-on')).toEqual([]);
});
it('not before the policy is known', async () => {
asDesktop();
usePolicyStore.setState({ source: 'none' } as never);
enforcePolicy();
await new Promise((resolve) => setTimeout(resolve, 5));
expect(useOfflineStore.getState().enabled).toEqual([]);
});
it('not after the user turned it off', async () => {
asDesktop();
enforcePolicy();
await vi.waitFor(() => expect(useOfflineStore.getState().enabled).toEqual(['acc']));
await disableOffline('acc');
enforcePolicy();
await new Promise((resolve) => setTimeout(resolve, 5));
expect(useOfflineStore.getState().enabled).toEqual([]);
expect(useOfflineStore.getState().choices.acc?.on).toBe(false);
});
});
describe('a browser', () => {
it('stays opt-in', async () => {
enforcePolicy();
await new Promise((resolve) => setTimeout(resolve, 5));
expect(useOfflineStore.getState().enabled).toEqual([]);
expect(notices).toEqual([]);
await enableOffline('acc');
expect(useOfflineStore.getState().enabled).toEqual(['acc']);
});
});
+1 -1
View File
@@ -20,7 +20,7 @@ beforeEach(async () => {
setStoreBackend(new InProcessStore());
localStorage.clear();
useOfflineStore.setState({ enabled: [], outbox: [], pendingChanges: 0, browserOffline: false, downServers: [], startedOffline: false });
usePolicyStore.setState({ loaded: true, subject: null, subjectPending: false, policy: { ...usePolicyStore.getState().policy, organisation: DEFAULT_ORGANISATION_POLICY } } as never);
usePolicyStore.setState({ loaded: true, subject: { email: 'alice@example.org', groups: [] }, subjectPending: false, policy: { ...usePolicyStore.getState().policy, organisation: DEFAULT_ORGANISATION_POLICY } } as never);
useAccountStore.setState({ accounts: [{ id: 'acc', label: 'alice', serverUrl: 'https://mail.example.org', username: 'alice@example.org' } as never] });
});
+22 -2
View File
@@ -4,7 +4,9 @@ import { setStoreBackend } from '@/lib/store/client/api';
import { InProcessStore } from '@/lib/store/__tests__/helpers/in-process-store';
import { forgetLegacyMove, LEGACY_DB_NAME, moveLegacyOutbox } from '../migrate-legacy';
import { readQueue } from '../queue';
import { useOfflineStore } from '../state';
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
import { effectiveScope, platformDefault, userChoiceFor } from '../scope';
import { OFFLINE_SCOPE_KEY, useOfflineStore, writeChoice } from '../state';
/** The old database, exactly as lib/offline/db.ts laid it out. */
const STORES = ['keys', 'emails', 'bodies', 'blobs', 'misc', 'ledger', 'queue', 'uploads'];
@@ -72,7 +74,8 @@ beforeEach(async () => {
store = new InProcessStore();
setStoreBackend(store);
forgetLegacyMove();
useOfflineStore.setState({ enabled: ['acc'] });
localStorage.clear();
useOfflineStore.setState({ enabled: ['acc'], choices: {} });
await new Promise<void>((resolve) => {
const request = indexedDB.deleteDatabase(LEGACY_DB_NAME);
request.onsuccess = () => resolve();
@@ -172,6 +175,23 @@ describe('moving the old outbox into the store', () => {
expect(await store.area('acc', 'mail.session')).toMatchObject({ apiUrl: 'https://old/jmap' });
});
it('keeps the account on, with the 30 days it had, and writes that down as its choice', async () => {
await seedOld();
await moveLegacyOutbox();
const state = useOfflineStore.getState();
expect(state.enabled).toContain('acc');
expect(state.choices.acc).toMatchObject({ on: true, days: 30 });
expect(JSON.parse(localStorage.getItem(OFFLINE_SCOPE_KEY)!).acc.days).toBe(30);
expect(effectiveScope('acc', userChoiceFor('acc'), DEFAULT_ORGANISATION_POLICY.devices, platformDefault()).scope.days).toBe(30);
});
it('never takes a window the user already picked', async () => {
await seedOld();
writeChoice('acc', { on: true, days: 365 });
await moveLegacyOutbox();
expect(useOfflineStore.getState().choices.acc.days).toBe(365);
});
it('never creates the old database when it isn\'t there', async () => {
await moveLegacyOutbox();
expect(await oldExists()).toBe(false);
+4 -1
View File
@@ -56,7 +56,7 @@ beforeEach(async () => {
forgetLegacyMove();
localStorage.clear();
useOfflineStore.setState({ enabled: [], outbox: [], pendingChanges: 0, conflicts: [], browserOffline: false, downServers: [], startedOffline: false });
usePolicyStore.setState({ loaded: true, subject: null, policy: { ...usePolicyStore.getState().policy, organisation: DEFAULT_ORGANISATION_POLICY } });
usePolicyStore.setState({ loaded: true, subject: { email: 'alice@example.org', groups: [] }, policy: { ...usePolicyStore.getState().policy, organisation: DEFAULT_ORGANISATION_POLICY } });
useAccountStore.setState({ accounts: [{ id: 'acc', label: 'alice', serverUrl: 'https://mail.example.org', username: 'alice@example.org' } as never] });
});
@@ -336,6 +336,8 @@ describe('a send whose response got lost', () => {
it('a lookup that can\'t decide leaves it uncertain for the user, who can still send it', async () => {
const client = fakeClient();
const entry = await uncertainSend(client);
// The store's own sync mustn't take the failure meant for the lookup.
await store.sync('acc');
server.failNext.set('Email/query', 'forbidden');
await flush(client);
expect(client.sendEmail).not.toHaveBeenCalled();
@@ -424,6 +426,7 @@ describe('wiping offline data', () => {
useAuthStore.setState({ client: client as never, activeAccountId: 'acc' });
writeEnabled(['acc']);
stop = startOfflineMode();
usePolicyStore.getState().setPolicySubject({ email: 'alice@example.org', groups: [] });
usePolicyStore.setState({ policy: { ...usePolicyStore.getState().policy, organisation: { ...DEFAULT_ORGANISATION_POLICY, devices: { ...DEFAULT_ORGANISATION_POLICY.devices, noOffline: true } } } });
enforcePolicy();
await vi.waitFor(async () => expect(useOfflineStore.getState().enabled).toEqual([]));
+1 -1
View File
@@ -22,7 +22,7 @@ beforeEach(() => {
setStoreBackend(store);
seen.calls = 0;
seen.onCall = () => {};
usePolicyStore.setState({ loaded: true, subject: null, policy: { ...usePolicyStore.getState().policy, organisation: DEFAULT_ORGANISATION_POLICY } });
usePolicyStore.setState({ loaded: true, subject: { email: 'alice@example.org', groups: [] }, policy: { ...usePolicyStore.getState().policy, organisation: DEFAULT_ORGANISATION_POLICY } });
useAccountStore.setState({ accounts: [{ id: 'acc', serverUrl: 'https://mail.example.org', username: 'alice@example.org' } as never] });
});
+2 -2
View File
@@ -56,7 +56,7 @@ function fakeClient(server = 'https://mail.example.org', overrides: Record<strin
}
function setPolicyOrg(organisation: Record<string, unknown>) {
usePolicyStore.setState({ loaded: true, source: 'server', subject: null, subjectPending: false, policy: { ...usePolicyStore.getState().policy, organisation: { ...DEFAULT_ORGANISATION_POLICY, ...organisation } as never } });
usePolicyStore.setState({ loaded: true, source: 'server', subject: { email: 'alice@example.org', groups: [] }, subjectPending: false, policy: { ...usePolicyStore.getState().policy, organisation: { ...DEFAULT_ORGANISATION_POLICY, ...organisation } as never } });
}
function setOnline(online: boolean) {
@@ -368,7 +368,7 @@ describe('what a sign-out would delete', () => {
});
it('answers with the last known count when the store hangs', async () => {
useOfflineStore.setState({ outbox: [{ id: 1, account: 'acc', createdAt: 1, subject: 'A', to: [], status: 'waiting' }] as never, pendingChanges: 2 });
useOfflineStore.setState({ outbox: [{ id: 1, account: 'acc', createdAt: 1, subject: 'A', to: [], status: 'waiting' }] as never, changesBy: { acc: 2 } });
const hung: Array<(error: Error) => void> = [];
vi.spyOn(store, 'area').mockImplementation(() => new Promise((_resolve, reject) => { hung.push(reject); }));
expect(await unsentCount('acc', 10)).toEqual({ messages: 1, changes: 2 });
+189
View File
@@ -0,0 +1,189 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
import { DEFAULT_SCOPE } from '@/lib/store/area';
import {
attachmentCapOptions,
effectiveScope,
LEGACY_CHOICE,
offlineDefaultOn,
olderThanCap,
platformDefault,
userChoiceFor,
windowOptions,
} from '../scope';
import { offlineScopeFor, useOfflineStore } from '../state';
const NO_LIMITS = DEFAULT_ORGANISATION_POLICY.devices;
const MB = 1024 * 1024;
const BROWSER = { on: false, scope: DEFAULT_SCOPE };
const DESKTOP = { on: true, scope: DEFAULT_SCOPE };
describe('effectiveScope', () => {
it('the default when nothing else says', () => {
expect(effectiveScope('a', null, NO_LIMITS, BROWSER)).toMatchObject({ account: 'a', enabled: false, scope: DEFAULT_SCOPE });
expect(effectiveScope('a', null, NO_LIMITS, DESKTOP).enabled).toBe(true);
});
it('the user over the default', () => {
const effective = effectiveScope('a', { on: true, days: 30, attachments: false, maxAttachmentBytes: 5 * MB }, NO_LIMITS, BROWSER);
expect(effective.enabled).toBe(true);
expect(effective.scope).toEqual({ ...DEFAULT_SCOPE, days: 30, attachments: false, maxAttachmentBytes: 5 * MB, orgMaxFileBytes: null });
expect(effectiveScope('a', { on: false }, NO_LIMITS, DESKTOP).enabled).toBe(false);
expect(effectiveScope('a', { days: null }, NO_LIMITS, BROWSER).scope.days).toBeNull();
});
it('the organisation over the user and over the desktop default', () => {
const policy = { ...NO_LIMITS, noOffline: true };
expect(effectiveScope('a', { on: true }, policy, BROWSER).enabled).toBe(false);
expect(effectiveScope('a', null, policy, DESKTOP).enabled).toBe(false);
expect(effectiveScope('a', { on: true }, policy, DESKTOP).locked.offline).toBe(true);
});
it('a cap lowers the choice and the default, also "everything", and never raises', () => {
const policy = { ...NO_LIMITS, offlineMaxDays: 60, offlineMaxAttachmentMb: 10, offlineMaxTotalMb: 100 };
const capped = effectiveScope('a', { days: null, maxAttachmentBytes: 500 * MB }, policy, DESKTOP);
expect(capped.scope).toMatchObject({ days: 60, attachments: true, maxAttachmentBytes: 10 * MB, maxTotalBytes: 100 * MB });
expect(capped.locked).toMatchObject({ days: true, maxAttachment: true, maxTotal: true, attachments: false });
// Sent on its own: a pin obeys this one, not the user's size.
expect(capped.scope.orgMaxFileBytes).toBe(10 * MB);
expect(effectiveScope('a', { maxAttachmentBytes: 5 * MB }, NO_LIMITS, BROWSER).scope.orgMaxFileBytes).toBeNull();
expect(effectiveScope('a', null, { ...policy, offlineMaxDays: 30 }, DESKTOP).scope.days).toBe(30);
const under = effectiveScope('a', { days: 30, maxAttachmentBytes: 5 * MB }, policy, BROWSER);
expect(under.scope.days).toBe(30);
expect(under.scope.maxAttachmentBytes).toBe(5 * MB);
});
it('forbidden attachments stay off whatever the user chose', () => {
const effective = effectiveScope('a', { attachments: true }, { ...NO_LIMITS, noOfflineAttachments: true }, DESKTOP);
expect(effective.scope.attachments).toBe(false);
expect(effective.locked.attachments).toBe(true);
});
});
describe('the platform default', () => {
afterEach(() => { delete (window as { bulwarkDesktop?: unknown }).bulwarkDesktop; });
it('is off in a browser and on with the desktop bridge', () => {
expect(offlineDefaultOn()).toBe(false);
expect(platformDefault().on).toBe(false);
(window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1 };
expect(offlineDefaultOn()).toBe(true);
expect(platformDefault().on).toBe(true);
});
});
describe('the OfflineEnabled policy of the desktop app', () => {
const withPolicy = (OfflineEnabled: unknown) => {
(window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, policy: { get: () => ({ OfflineEnabled }) } };
};
afterEach(() => { delete (window as { bulwarkDesktop?: unknown }).bulwarkDesktop; });
it('recommended off changes the default, and a user can still turn it on', () => {
withPolicy({ value: false, locked: false, source: 'machine' });
expect(offlineDefaultOn()).toBe(false);
expect(effectiveScope('a', null, NO_LIMITS, platformDefault()).enabled).toBe(false);
expect(effectiveScope('a', { on: true }, NO_LIMITS, platformDefault()).enabled).toBe(true);
});
it('the shipped default is no recommendation', () => {
withPolicy({ value: false, locked: false, source: 'default' });
expect(offlineDefaultOn()).toBe(true);
});
it('forced beats what the user chose, either way', () => {
withPolicy({ value: true, locked: true, source: 'machine' });
expect(effectiveScope('a', { on: false }, NO_LIMITS, platformDefault())).toMatchObject({ enabled: true, locked: { forced: true } });
withPolicy({ value: false, locked: true, source: 'machine' });
expect(effectiveScope('a', { on: true }, NO_LIMITS, platformDefault()).enabled).toBe(false);
});
it('the organisation still beats it', () => {
withPolicy({ value: true, locked: true, source: 'machine' });
expect(effectiveScope('a', null, { ...NO_LIMITS, noOffline: true }, platformDefault()).enabled).toBe(false);
});
it('a shell without the member, or a member that throws, is the plain default', () => {
(window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1 };
expect(offlineDefaultOn()).toBe(true);
(window as { bulwarkDesktop?: unknown }).bulwarkDesktop = { version: 1, policy: { get: () => { throw new Error('gone'); } } };
expect(platformDefault()).toMatchObject({ on: true, forced: null });
});
});
describe('the seam for what a user chose', () => {
it('reads bulwark-offline-scope over the defaults, without the switch', () => {
localStorage.setItem('bulwark-offline-scope', JSON.stringify({ acc: { on: true, days: 30 } }));
expect(offlineScopeFor('acc')).toEqual({ ...DEFAULT_SCOPE, days: 30 });
expect(offlineScopeFor('other')).toEqual(DEFAULT_SCOPE);
localStorage.removeItem('bulwark-offline-scope');
});
});
describe('what the user chose', () => {
beforeEach(() => useOfflineStore.setState({ enabled: [], choices: {} }));
it('nothing for an account that never had it on', () => {
expect(userChoiceFor('a')).toBeNull();
});
it('an account on from before the local store keeps the 30 days it had', () => {
useOfflineStore.setState({ enabled: ['a'] });
expect(userChoiceFor('a')).toEqual(LEGACY_CHOICE);
expect(LEGACY_CHOICE.days).toBe(30);
expect(effectiveScope('a', userChoiceFor('a'), NO_LIMITS, BROWSER).scope.days).toBe(30);
});
it('a stored choice wins', () => {
useOfflineStore.setState({ enabled: ['a'], choices: { a: { on: true, days: 365 } } });
expect(userChoiceFor('a')?.days).toBe(365);
});
});
describe('what the page offers', () => {
it('every window without a cap', () => {
expect(windowOptions(0)).toEqual([30, 90, 365, null]);
});
it('what fits under a cap, and the cap itself', () => {
expect(windowOptions(90)).toEqual([30, 90]);
expect(windowOptions(60)).toEqual([30, 60]);
expect(windowOptions(7)).toEqual([7]);
expect(attachmentCapOptions(0)).toEqual([5, 25, 100, 500]);
expect(attachmentCapOptions(10)).toEqual([5, 10]);
});
});
describe('Lite', () => {
it('reads the caps from the same policy.json', async () => {
const { applyLitePolicy } = await import('@/lib/lite-config');
const policy = applyLitePolicy({ organisation: { devices: { offlineMaxDays: 30, noOfflineAttachments: true, offlineMaxTotalMb: 'lots' } } } as never);
expect(policy.organisation?.devices).toMatchObject({ offlineMaxDays: 30, noOfflineAttachments: true, offlineMaxTotalMb: 0 });
// A cap is lower than anything, also where nothing else is known.
expect(effectiveScope('a', { days: null }, policy.organisation!.devices, BROWSER).scope).toMatchObject({ days: 30, attachments: false });
});
it('has no store to apply them to, whatever a caller asks', async () => {
vi.resetModules();
vi.doMock('@/lib/lite', async (original) => ({ ...(await original<typeof import('@/lib/lite')>()), IS_LITE: true }));
try {
const { enableOffline, offlineSupported } = await import('../offline-mode');
const { useOfflineStore: lite } = await import('../state');
expect(offlineSupported()).toBe(false);
await enableOffline('a');
expect(lite.getState().enabled).toEqual([]);
} finally {
vi.doUnmock('@/lib/lite');
vi.resetModules();
}
});
});
describe('olderThanCap', () => {
const now = Date.UTC(2026, 10, 3);
it('only with a cap, and only past it', () => {
expect(olderThanCap('2026-01-01T00:00:00Z', 0, now)).toBe(false);
expect(olderThanCap('2026-01-01T00:00:00Z', 30, now)).toBe(true);
expect(olderThanCap('2026-10-30T00:00:00Z', 30, now)).toBe(false);
expect(olderThanCap(undefined, 30, now)).toBe(false);
});
});
@@ -0,0 +1,416 @@
import 'fake-indexeddb/auto';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DEFAULT_ORGANISATION_POLICY } from '@/lib/admin/organisation-policy';
import { purgeSignedOutData } from '@/lib/sign-out-cleanup';
import { configureStore, getStore, setStoreBackend } from '@/lib/store/client/api';
import { deleteKeyDatabase, getAccountRecord, openAccountKey } from '@/lib/store/client/keys';
import { FAKE_SESSION } from '@/lib/store/__tests__/helpers/fake-jmap';
import { usePolicyStore } from '@/stores/policy-store';
import {
enableOffline,
registerOfflineHooks,
setOfflineNotifier,
startOfflineMode,
wipeAccount,
wipeEverything,
type OfflineNotice,
} from '../offline-mode';
import { readPersistence, requestPersistence, safariMayDelete } from '../storage-health';
import { markEvicted, OFFLINE_HEALTH_KEY, OFFLINE_SCOPE_KEY, useOfflineStore, writeChoice, writeEnabled, writeHealth } from '../state';
/** Everything on one page: a BroadcastChannel that also reaches other objects here, as in a browser. */
class FakeChannel {
static open = new Set<FakeChannel>();
onmessage: ((event: MessageEvent) => void) | null = null;
constructor(readonly name: string) {
FakeChannel.open.add(this);
}
postMessage(data: unknown): void {
for (const channel of FakeChannel.open) {
if (channel !== this && channel.name === this.name) channel.onmessage?.({ data } as MessageEvent);
}
}
close(): void {
FakeChannel.open.delete(this);
}
}
class FakeWorker {
static all: FakeWorker[] = [];
/** What the worker found on OPFS: no database under this name. */
static created = false;
onmessage: ((event: MessageEvent) => void) | null = null;
posted: Array<Record<string, unknown>> = [];
constructor() {
FakeWorker.all.push(this);
}
postMessage(message: Record<string, unknown>): void {
this.posted.push(message);
if (message.kind === 'open') queueMicrotask(() => this.onmessage?.({ data: { kind: 'result', id: message.id, result: { created: FakeWorker.created } } } as MessageEvent));
if (message.kind === 'prune-files') queueMicrotask(() => this.onmessage?.({ data: { kind: 'result', id: message.id } } as MessageEvent));
}
terminate(): void {}
}
const rpcs: Array<{ method: string; params: { name?: string; account?: string } }> = [];
/** The worker's side of the channel: an empty store. */
function ownerOnChannel(): void {
const owner = new FakeChannel('bulwark-store-rpc');
owner.onmessage = (event) => {
const message = event.data as { kind: string; id: string; from: string; method: string; params: { name?: string } };
if (message.kind === 'who-owns') owner.postMessage({ kind: 'owner-changed', owner: 'worker' });
if (message.kind !== 'rpc') return;
rpcs.push({ method: message.method, params: message.params });
const name = message.params.name ?? '';
const result = name === 'outbox.read' ? { patches: 0, entries: [], conflicts: [] }
: name.endsWith('.conflicts') ? []
: name.endsWith('.pending') ? 0
: name === 'store.usage' ? { bytes: 0, parts: [] }
: null;
owner.postMessage({ kind: 'reply', id: message.id, to: message.from, result });
};
owner.postMessage({ kind: 'owner-changed', owner: 'worker' });
}
const notices: OfflineNotice[] = [];
function setStorage(storage: Record<string, unknown>): void {
Object.defineProperty(window.navigator, 'storage', { configurable: true, value: storage });
}
function setUserAgent(value: string): void {
Object.defineProperty(window.navigator, 'userAgent', { configurable: true, value });
}
const realUserAgent = window.navigator.userAgent;
beforeEach(() => {
localStorage.clear();
deleteKeyDatabase();
FakeChannel.open.clear();
FakeWorker.all = [];
FakeWorker.created = false;
rpcs.length = 0;
notices.length = 0;
vi.stubGlobal('BroadcastChannel', FakeChannel);
vi.stubGlobal('Worker', FakeWorker);
vi.stubGlobal('FileSystemFileHandle', class {});
setStorage({ getDirectory: async () => ({ removeEntry: async () => {} }), persist: async () => true, persisted: async () => true });
Object.defineProperty(window.navigator, 'locks', { configurable: true, value: { request: async (_name: string, ...rest: unknown[]) => (rest.at(-1) as () => unknown)() } });
useOfflineStore.setState({
enabled: [], outbox: [], pendingChanges: 0, conflicts: [], areaConflicts: [], usage: {}, usageBytes: 0, choices: {}, health: {},
storeLost: [], storageFull: {}, progress: {}, persist: 'unknown', syncing: [], lastSyncedAt: {}, browserOffline: false, downServers: [], startedOffline: false,
});
usePolicyStore.setState({ loaded: true, source: 'server', subject: { email: 'alice@example.org', groups: [] }, subjectPending: false, policy: { ...usePolicyStore.getState().policy, organisation: DEFAULT_ORGANISATION_POLICY } } as never);
setStoreBackend(null);
setOfflineNotifier((notice) => notices.push(notice));
registerOfflineHooks();
});
afterEach(() => {
getStore().stop();
configureStore(null);
setStoreBackend(null);
setOfflineNotifier(() => {});
vi.unstubAllGlobals();
delete (window.navigator as { storage?: unknown }).storage;
delete (window.navigator as { locks?: unknown }).locks;
setUserAgent(realUserAgent);
});
/** Opens an account in this tab, as the owner, the way the page does. */
async function openAsOwner(account: string): Promise<FakeWorker> {
useOfflineStore.setState((s) => ({ enabled: [...s.enabled, account] }));
await getStore().ensureOpen(account, FAKE_SESSION);
const worker = FakeWorker.all.at(-1)!;
worker.onmessage?.({ data: { kind: 'owner' } } as MessageEvent);
ownerOnChannel();
await vi.waitFor(() => expect(worker.posted.some((m) => m.kind === 'open' && (m.params as { account: string }).account === account)).toBe(true));
return worker;
}
describe('a store the browser cleared', () => {
const hadStore = (account: string) => localStorage.setItem('bulwark-store-opened', JSON.stringify([account]));
it('a key with no file: warns, with how many waiting items went', async () => {
await openAccountKey('acc');
hadStore('acc');
writeHealth('acc', { pending: 3 });
FakeWorker.created = true;
await openAsOwner('acc');
await vi.waitFor(() => expect(notices).toContainEqual({ kind: 'store-reset', lost: 3 }));
const state = useOfflineStore.getState();
expect(state.storeLost).toEqual(['acc']);
expect(state.health.acc.reset?.lost).toBe(3);
expect(state.health.acc.pending).toBe(0);
});
it('no key where one was made: warns too', async () => {
hadStore('acc');
await openAsOwner('acc');
await vi.waitFor(() => expect(notices).toContainEqual({ kind: 'store-reset', lost: 0 }));
});
it('a first open is not an eviction', async () => {
FakeWorker.created = true;
const worker = await openAsOwner('acc');
await vi.waitFor(() => expect(worker.posted.some((m) => m.kind === 'open')).toBe(true));
await new Promise((resolve) => setTimeout(resolve, 5));
expect(notices.filter((n) => n.kind === 'store-reset')).toEqual([]);
expect(useOfflineStore.getState().health.acc?.reset).toBeUndefined();
});
it('a normal start is not one either', async () => {
await openAccountKey('acc');
hadStore('acc');
writeHealth('acc', { pending: 3 });
await openAsOwner('acc');
await new Promise((resolve) => setTimeout(resolve, 5));
expect(notices).toEqual([]);
expect(useOfflineStore.getState().health.acc.pending).toBe(3);
});
it('tells nobody who wasn\'t signed in to an account dropped by the app', async () => {
await openAccountKey('acc');
hadStore('acc');
writeHealth('acc', { pending: 3 });
useOfflineStore.setState({ enabled: ['acc'] });
markEvicted('acc');
useOfflineStore.setState({ enabled: [] });
FakeWorker.created = true;
await openAsOwner('acc');
await new Promise((resolve) => setTimeout(resolve, 5));
expect(notices).toEqual([]);
expect(useOfflineStore.getState().health.acc.reset).toBeUndefined();
});
it('says so also when the store opened before the host component was there', async () => {
setOfflineNotifier(null);
await openAccountKey('acc');
hadStore('acc');
FakeWorker.created = true;
const worker = await openAsOwner('acc');
await vi.waitFor(() => expect(worker.posted.some((m) => m.kind === 'open')).toBe(true));
await vi.waitFor(() => expect(useOfflineStore.getState().storeLost).toEqual(['acc']));
expect(notices).toEqual([]);
setOfflineNotifier((notice) => notices.push(notice));
expect(notices).toContainEqual({ kind: 'store-reset', lost: 0 });
});
it('off and on again is no eviction', async () => {
await openAccountKey('acc');
hadStore('acc');
writeEnabled(['acc']);
await wipeAccount('acc');
expect(localStorage.getItem('bulwark-store-opened')).toBeNull();
FakeWorker.created = true;
await openAsOwner('acc');
await new Promise((resolve) => setTimeout(resolve, 5));
expect(notices.filter((n) => n.kind === 'store-reset')).toEqual([]);
});
it('keeps only numbers about it outside the store', () => {
writeHealth('acc', { pending: 2, reset: { at: 5, lost: 2 }, lastSync: 9 });
expect(JSON.parse(localStorage.getItem(OFFLINE_HEALTH_KEY)!)).toEqual({ acc: { pending: 2, reset: { at: 5, lost: 2 }, lastSync: 9 } });
});
});
describe('the scope an account opens with', () => {
const setDays = (offlineMaxDays: number) => usePolicyStore.setState({
policy: { ...usePolicyStore.getState().policy, organisation: { ...DEFAULT_ORGANISATION_POLICY, devices: { ...DEFAULT_ORGANISATION_POLICY.devices, offlineMaxDays } } },
} as never);
const openParams = (worker: FakeWorker) => worker.posted.find((m) => m.kind === 'open')?.params as { scope?: { days: number | null } } | undefined;
function holdPolicy(): () => void {
let release = () => {};
const fetchPolicy = vi.fn(() => new Promise<void>((resolve) => { release = resolve; }));
usePolicyStore.setState({ fetchPolicy } as never);
return () => release();
}
afterEach(() => { usePolicyStore.setState({ fetchPolicy: realFetchPolicy } as never); });
const realFetchPolicy = usePolicyStore.getState().fetchPolicy;
it('none from a tab that just took over, and the cap once it has heard the policy', async () => {
const heard = holdPolicy();
setDays(14);
const worker = await openAsOwner('acc');
await vi.waitFor(() => expect(openParams(worker)).toBeTruthy());
// This tab's policy may be old: the store keeps what it has.
expect(openParams(worker)?.scope).toBeUndefined();
expect(rpcs.filter((rpc) => rpc.method === 'setScope')).toEqual([]);
heard();
await vi.waitFor(() => expect(rpcs.filter((rpc) => rpc.method === 'setScope')).toHaveLength(1));
expect((rpcs.find((rpc) => rpc.method === 'setScope')!.params as unknown as { scope: { days: number } }).scope.days).toBe(14);
});
it('a stale cap the fetch corrects isn\'t what goes out', async () => {
const heard = holdPolicy();
setDays(365);
await openAsOwner('acc');
setDays(14);
heard();
await vi.waitFor(() => expect(rpcs.filter((rpc) => rpc.method === 'setScope')).toHaveLength(1));
expect((rpcs.find((rpc) => rpc.method === 'setScope')!.params as unknown as { scope: { days: number } }).scope.days).toBe(14);
});
it('none before the policy is known, at the real inputs', async () => {
usePolicyStore.setState({ source: 'none' } as never);
const worker = await openAsOwner('acc');
await vi.waitFor(() => expect(openParams(worker)).toBeTruthy());
await new Promise((resolve) => setTimeout(resolve, 5));
expect(rpcs.filter((rpc) => rpc.method === 'setScope')).toEqual([]);
});
});
describe('a failed write', () => {
it('says the device is full, at most once in a while, until a sync goes through', async () => {
const worker = await openAsOwner('acc');
const event = (e: unknown) => worker.onmessage?.({ data: { kind: 'event', event: e } } as MessageEvent);
event({ type: 'error', account: 'acc', area: 'storage', message: 'SQLITE_FULL' });
event({ type: 'error', account: 'acc', area: 'storage', message: 'SQLITE_FULL' });
expect(notices.filter((n) => n.kind === 'storage-failed').length).toBeLessThanOrEqual(1);
expect(useOfflineStore.getState().storageFull.acc).toBeGreaterThan(0);
event({ type: 'synced', account: 'acc', at: 123 });
expect(useOfflineStore.getState().storageFull.acc).toBeUndefined();
expect(useOfflineStore.getState().health.acc?.lastSync).toBe(123);
});
});
describe('asking the browser to keep it', () => {
it('asks when offline is turned on, and remembers a refusal', async () => {
const persist = vi.fn(async () => false);
setStorage({ persist, persisted: async () => false, getDirectory: async () => ({}) });
await enableOffline('acc').catch(() => {});
await vi.waitFor(() => expect(useOfflineStore.getState().persist).toBe('refused'));
expect(persist).toHaveBeenCalledTimes(1);
});
it('asks again after an install', async () => {
const persist = vi.fn(async () => true);
setStorage({ persist, persisted: async () => false, getDirectory: async () => ({}) });
writeEnabled(['acc']);
const stop = startOfflineMode();
window.dispatchEvent(new Event('appinstalled'));
await vi.waitFor(() => expect(persist).toHaveBeenCalled());
await vi.waitFor(() => expect(useOfflineStore.getState().persist).toBe('granted'));
stop();
registerOfflineHooks();
});
it('tells a browser without the API from a refusal', async () => {
setStorage({});
expect(await requestPersistence()).toBe('unsupported');
expect(await readPersistence()).toBe('unsupported');
});
});
describe('the Safari hint', () => {
const IPHONE = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Mobile/15E148 Safari/604.1';
const IPAD_CHROME = 'Mozilla/5.0 (iPad; CPU OS 17_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/126.0 Mobile/15E148 Safari/604.1';
const MAC_SAFARI = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.5 Safari/605.1.15';
const MAC_CHROME = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36';
const WIN_EDGE = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36 Edg/126.0';
const ANDROID_WEBVIEW = 'Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/126.0 Mobile Safari/537.36';
const FIREFOX = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14.5; rv:128.0) Gecko/20100101 Firefox/128.0';
const ELECTRON = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_5) AppleWebKit/537.36 (KHTML, like Gecko) Bulwark/1.0 Chrome/126.0 Electron/31.0 Safari/537.36';
it('shows in Safari and on iOS browsers that are not installed', () => {
for (const ua of [IPHONE, IPAD_CHROME, MAC_SAFARI]) expect(safariMayDelete(ua, false)).toBe(true);
});
it('not once installed', () => {
for (const ua of [IPHONE, MAC_SAFARI]) expect(safariMayDelete(ua, true)).toBe(false);
});
it('nowhere else', () => {
for (const ua of [MAC_CHROME, WIN_EDGE, ANDROID_WEBVIEW, FIREFOX, ELECTRON]) expect(safariMayDelete(ua, false)).toBe(false);
});
it('reads the real browser by default', () => {
setUserAgent(MAC_SAFARI);
expect(safariMayDelete()).toBe(true);
setUserAgent(MAC_CHROME);
expect(safariMayDelete()).toBe(false);
});
});
describe('removing an account', () => {
it('takes its data, key, choice and counts, and leaves the other account alone', async () => {
const { record } = await openAccountKey('gone');
await openAccountKey('stays');
writeEnabled(['gone', 'stays']);
writeChoice('gone', { days: 30 });
writeChoice('stays', { days: 90 });
writeHealth('gone', { pending: 1 });
await openAsOwner('stays');
await wipeAccount('gone');
expect(await getAccountRecord('gone')).toBeNull();
expect(await getAccountRecord('stays')).not.toBeNull();
expect(rpcs).toContainEqual({ method: 'removeAccount', params: { account: 'gone', file: record.file } });
expect(useOfflineStore.getState().choices).toEqual({ stays: { days: 90 } });
expect(useOfflineStore.getState().health.gone).toBeUndefined();
expect(JSON.parse(localStorage.getItem(OFFLINE_SCOPE_KEY)!)).toEqual({ stays: { days: 90 } });
});
it('with the last account, deletes the pool and every key', async () => {
const removeEntry = vi.fn(async () => {});
setStorage({ getDirectory: async () => ({ removeEntry }), persist: async () => true });
await openAccountKey('acc');
writeEnabled(['acc']);
writeChoice('acc', { days: 30 });
await wipeAccount('acc');
expect(removeEntry).toHaveBeenCalledWith('.bulwark-store', { recursive: true });
await vi.waitFor(async () => expect(await getAccountRecord('acc')).toBeNull());
expect(localStorage.getItem(OFFLINE_SCOPE_KEY)).toBeNull();
expect(localStorage.getItem(OFFLINE_HEALTH_KEY)).toBeNull();
});
it('wipeEverything leaves nothing either', async () => {
const removeEntry = vi.fn(async () => {});
setStorage({ getDirectory: async () => ({ removeEntry }) });
await openAccountKey('a');
await openAccountKey('b');
writeEnabled(['a', 'b']);
await wipeEverything();
expect(removeEntry).toHaveBeenCalled();
await vi.waitFor(async () => expect(await getAccountRecord('a')).toBeNull());
expect(await getAccountRecord('b')).toBeNull();
expect(useOfflineStore.getState().enabled).toEqual([]);
});
});
describe('a full sign-out', () => {
it('that keeps the local store for a new sign-in keeps what was chosen for it too', () => {
writeEnabled(['acc']);
writeChoice('acc', { days: 30 });
writeHealth('acc', { pending: 2 });
purgeSignedOutData({ keepLocalStore: true });
expect(JSON.parse(localStorage.getItem(OFFLINE_SCOPE_KEY)!).acc.days).toBe(30);
expect(JSON.parse(localStorage.getItem(OFFLINE_HEALTH_KEY)!).acc.pending).toBe(2);
expect(useOfflineStore.getState().choices.acc.days).toBe(30);
});
it('deletes the files, every key and what was stored about them', async () => {
const removeEntry = vi.fn(async () => {});
setStorage({ getDirectory: async () => ({ removeEntry }) });
await openAccountKey('acc');
writeEnabled(['acc']);
writeChoice('acc', { days: 30 });
writeHealth('acc', { pending: 2 });
purgeSignedOutData();
await vi.waitFor(() => expect(removeEntry).toHaveBeenCalledWith('.bulwark-store', { recursive: true }));
await vi.waitFor(async () => expect(await getAccountRecord('acc')).toBeNull());
for (const key of ['bulwark-offline-accounts', OFFLINE_SCOPE_KEY, OFFLINE_HEALTH_KEY]) expect(localStorage.getItem(key)).toBeNull();
expect(useOfflineStore.getState().choices).toEqual({});
expect(useOfflineStore.getState().health).toEqual({});
});
});
@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest';
import { minutesLeft, nextProgress, progressWorthShowing } from '../sync-progress';
const event = (phase: string, done: number, total: number | null) => ({ area: 'mail', phase, done, total });
describe('the time left', () => {
it('says nothing until it has seen enough', () => {
const start = nextProgress(undefined, event('bodies', 0, 1000), 0);
expect(minutesLeft(start)).toBeNull();
expect(minutesLeft(nextProgress(start, event('bodies', 100, 1000), 1_000))).toBeNull();
});
it('works from the rate of this phase', () => {
const start = nextProgress(undefined, event('bodies', 0, 6000), 0);
// 100 a second: 59 s left rounds up.
expect(minutesLeft(nextProgress(start, event('bodies', 100, 6000), 1_000))).toBeNull();
expect(minutesLeft(nextProgress(start, event('bodies', 600, 6000), 6_000))).toBe(1);
});
it('while listing, counts the bodies that come after', () => {
const start = nextProgress(undefined, event('list', 0, 50_000), 0);
const left = minutesLeft(nextProgress(start, event('list', 7_000, 50_000), 5_000));
// Bench rig: 35 s to list, about 7 minutes in all.
expect(left).toBeGreaterThanOrEqual(5);
expect(left).toBeLessThanOrEqual(10);
});
it('starts over with each phase', () => {
const list = nextProgress(undefined, event('list', 1000, 1000), 0);
const bodies = nextProgress(list, event('bodies', 10, 1000), 60_000);
expect(bodies.startedAt).toBe(60_000);
expect(bodies.startedDone).toBe(10);
});
it('starts the sample over after a gap: time asleep isn\'t the rate', () => {
const start = nextProgress(undefined, event('bodies', 0, 10_000), 0);
const before = nextProgress(start, event('bodies', 100, 10_000), 10_000);
const after = nextProgress(before, event('bodies', 200, 10_000), 10_000 + 3_600_000);
expect(after.startedAt).toBe(10_000 + 3_600_000);
expect(after.startedDone).toBe(200);
expect(minutesLeft(after)).toBeNull();
});
it('has no time without a total, or when done', () => {
const open = nextProgress(undefined, event('list', 0, null), 0);
expect(minutesLeft(nextProgress(open, event('list', 500, null), 10_000))).toBeNull();
const done = nextProgress(undefined, event('bodies', 0, 100), 0);
expect(minutesLeft(nextProgress(done, event('bodies', 100, 100), 10_000))).toBeNull();
});
});
describe('what is worth a line', () => {
it('a big job or an unknown one, not a few new messages', () => {
expect(progressWorthShowing(undefined)).toBe(false);
expect(progressWorthShowing(nextProgress(undefined, event('bodies', 1, 3), 0))).toBe(false);
expect(progressWorthShowing(nextProgress(undefined, event('bodies', 1, 500), 0))).toBe(true);
expect(progressWorthShowing(nextProgress(undefined, event('list', 500, null), 0))).toBe(true);
});
});
+4 -1
View File
@@ -14,7 +14,8 @@ import { getStore } from '@/lib/store/client/api';
import type { StoreSession } from '@/lib/store/engine/transport';
import type { LegacyImport } from '@/lib/store/outbox/calls';
import type { OutboxError, OutboxStatus, OutboxSummary, PolicyFingerprint, SendItem } from '@/lib/store/outbox/items';
import { useOfflineStore } from './state';
import { LEGACY_CHOICE } from './scope';
import { useOfflineStore, writeChoice } from './state';
export const LEGACY_DB_NAME = 'bulwark-offline';
@@ -173,6 +174,8 @@ async function moveNow(): Promise<void> {
const keyRecord = await result(db.transaction('keys', 'readonly').objectStore('keys').get('aes')) as { key: CryptoKey } | undefined;
const key = keyRecord?.key ?? null;
for (const account of useOfflineStore.getState().enabled) {
// Keeps the window it had: a choice now, so a later default can't change it.
if (!useOfflineStore.getState().choices[account]) writeChoice(account, LEGACY_CHOICE);
const part = await readAccount(db, key, account);
const { session, ...content } = part;
if (session) await getStore().ensureOpen(account, session);
+327 -32
View File
@@ -24,14 +24,14 @@
import { IS_LITE } from '@/lib/lite';
import { desktopJmapBase } from '@/lib/desktop/jmap-route';
import { devicesForAccount } from '@/lib/device-policy';
import { devicesForAccount, devicesForNoOne, signedInAccountIds } from '@/lib/device-policy';
import { accountSubjectPending, useAccountSubjects } from '@/lib/org/account-subjects';
import { labelsPolicyFor } from '@/lib/outgoing/classification';
import { getSendGuardSource, guardState, guardsSending } from '@/lib/plugin-send-guards';
import { emitPushStateChange } from '@/lib/push-events';
import { requestOutboxSync } from '@/lib/pwa-notify/outbox-sync';
import { emitStoreEvent } from '@/lib/offline/store-events';
import { STORAGE_ERROR_AREA } from '@/lib/store/area';
import { STORAGE_ERROR_AREA, type StoreScope, type StoreUsage } from '@/lib/store/area';
import type { DraftError } from '@/lib/store/drafts/pending';
import { configureStore, getStore, type StorePageHooks } from '@/lib/store/client/api';
import { purgeStoreFiles } from '@/lib/store/client/purge';
@@ -41,6 +41,7 @@ import { useAuthStore } from '@/stores/auth-store';
import { useEmailStore } from '@/stores/email-store';
import { usePolicyStore, whenPolicySettled } from '@/stores/policy-store';
import { onBeforeSignOut, onFullSignOut } from '@/lib/sign-out-cleanup';
import { accountRefusedByDesktopPolicy } from '@/lib/desktop/refused-accounts';
import { generateMessageId } from '@/lib/jmap/client';
import { isNetworkFailure, SendNotStartedError, setOfflineHooks, type OfflineClientRef, type OfflineHooks, type OfflineMethodCall, type OfflineResponse } from '@/lib/jmap/offline-hooks';
import type { SendEmailResult } from '@/lib/jmap/types';
@@ -58,7 +59,36 @@ import {
type PolicyFingerprint,
} from './queue';
import { clearHandoffs } from '@/lib/launch-handoff';
import { clearEvicted, EVICTED_KEEP_MS, forgetMailSnapshot, isEvicted, offlineScopeFor, isOfflineNow, OFFLINE_ACCOUNTS_KEY, readEnabledFrom, readEvicted, visibleAccounts, serverKey, setServerDown, useOfflineStore, writeEnabled, type OutboxRow } from './state';
import {
clearEvicted,
EVICTED_KEEP_MS,
forgetAccountState,
forgetAllAccountState,
forgetMailSnapshot,
isEvicted,
isOfflineNow,
OFFLINE_ACCOUNTS_KEY,
OFFLINE_HEALTH_KEY,
OFFLINE_SCOPE_KEY,
readEnabledFrom,
readEvicted,
readStoredChoices,
readStoredHealth,
serverKey,
setServerDown,
useOfflineStore,
visibleAccounts,
writeChoice,
writeEnabled,
writeHealth,
type AreaConflict,
type OutboxRow,
type UserChoice,
} from './state';
import { effectiveScope, offlineDefaultOn, olderThanCap, platformDefault, userChoiceFor, type EffectiveScope } from './scope';
import { isStandaloneDisplayMode } from '@/lib/standalone-display';
import { readPersistence, requestPersistence, safariMayDelete } from './storage-health';
import { nextProgress } from './sync-progress';
import { flushQueue, LOOKUP_WAIT_MS, SENDING_STALE_MS, settleStaleSends } from './flush';
import { LEGACY_DB_NAME, moveLegacyOutbox, strandedLegacyMessages } from './migrate-legacy';
import { deleteShellCaches, tellServiceWorker } from './shell';
@@ -76,8 +106,9 @@ export type OfflineNotice =
| { kind: 'draft-conflict'; subject: string }
| { kind: 'storage-failed' }
| { kind: 'store-unavailable' }
| { kind: 'store-reset' }
| { kind: 'store-reset'; lost?: number }
| { kind: 'legacy-stranded' }
| { kind: 'default-on' }
| { kind: 'refused-delete' }
| { kind: 'refused-other' }
| { kind: 'forbidden' }
@@ -96,10 +127,10 @@ function notify(notice: OfflineNotice): void {
else if (earlyNotices.length < MOST_EARLY_NOTICES) earlyNotices.push(notice);
}
/** Where the notices go (the host component turns them into toasts). */
export function setOfflineNotifier(fn: (notice: OfflineNotice) => void): void {
/** Where the notices go (the host component turns them into toasts). Null: nothing is there at the moment. */
export function setOfflineNotifier(fn: ((notice: OfflineNotice) => void) | null): void {
notifier = fn;
for (const notice of earlyNotices.splice(0)) fn(notice);
if (fn) for (const notice of earlyNotices.splice(0)) fn(notice);
}
export function offlineNotify(notice: OfflineNotice): void {
@@ -132,6 +163,16 @@ export function offlineAllowed(account: string | null | undefined): boolean {
return offlineSupported() && !devicesForAccount(account ?? null).noOffline;
}
/** A dropped account's data isn't judged by the groups of whoever is signed in now. */
function policyOfKept(account: string) {
return isEvicted(account) ? devicesForNoOne() : devicesForAccount(account);
}
/** What this account keeps here: the organisation's policy over the user's choice over the platform's default. */
export function scopeForAccount(account: string): EffectiveScope {
return effectiveScope(account, userChoiceFor(account), policyOfKept(account), platformDefault());
}
/** "Keep mail on this device" is on for this account here, and allowed. */
export function offlineActive(account: string | null | undefined): boolean {
return !!account && useOfflineStore.getState().enabled.includes(account) && offlineAllowed(account);
@@ -160,6 +201,9 @@ function onlineClientFor(account: string) {
// ---- The store's way to this tab ----
/** How long a tab that took over the store waits for the policy before going on as if it hadn't heard. */
const OWNER_POLICY_WAIT_MS = 5_000;
/** A full disk stays full: once in a while is enough to say so. */
const STORAGE_NOTICE_MS = 30 * 60_000;
let lastStorageNotice = 0;
@@ -180,8 +224,10 @@ const storeHooks: StorePageHooks = {
return {
session: (client?.getSessionSnapshot() as StoreSession | undefined) ?? null,
auth: client?.getAuthHeader() ?? null,
// Until the policy is known the store keeps its last scope, so a slow
// start doesn't read as a wider window that has to be fetched.
scope: scopeDecided(account) ? scopeForAccount(account).scope : undefined,
invertedKeywordSort: client?.keywordSortInverted() ?? null,
scope: offlineScopeFor(account),
};
},
async refreshAuth(account) {
@@ -191,11 +237,21 @@ const storeHooks: StorePageHooks = {
await client.requestNetworkOnly([['Core/echo', {}, '0']]);
return client.getAuthHeader();
},
async ownerTaken() {
// This tab may have sat in the background: its policy can be old.
await Promise.race([
usePolicyStore.getState().fetchPolicy(),
new Promise((resolve) => setTimeout(resolve, OWNER_POLICY_WAIT_MS)),
]);
},
onStoreLost(account) {
// Whoever is here wasn't signed in to a dropped account: nothing to tell them.
if (isEvicted(account)) return;
const lost = useOfflineStore.getState().health[account]?.pending ?? 0;
useOfflineStore.setState((s) => ({ storeLost: [...new Set([...s.storeLost, account])] }));
notify({ kind: 'store-reset' });
writeHealth(account, { pending: 0, reset: { at: Date.now(), lost } });
notify({ kind: 'store-reset', lost });
void refreshQueueState();
},
onEvent(event) {
emitStoreEvent(event);
@@ -204,11 +260,22 @@ const storeHooks: StorePageHooks = {
if (client) reportStoreCreatedMail(client, event.jmap, event.ids);
}
if (event.type === 'synced') {
useOfflineStore.setState((s) => ({ lastSyncedAt: { ...s.lastSyncedAt, [event.account]: event.at } }));
useOfflineStore.setState((s) => {
const { [event.account]: _full, ...storageFull } = s.storageFull;
const { [event.account]: _progress, ...progress } = s.progress;
return { lastSyncedAt: { ...s.lastSyncedAt, [event.account]: event.at }, storageFull, progress };
});
writeHealth(event.account, { lastSync: event.at });
forgetMailSnapshot(event.account);
void refreshUsage();
// A pass also sends what the calendar and contacts have waiting.
void refreshQueueState();
}
if (event.type === 'progress') {
useOfflineStore.setState((s) => ({ progress: { ...s.progress, [event.account]: nextProgress(s.progress[event.account], event, Date.now()) } }));
}
if (event.type === 'error' && event.area === STORAGE_ERROR_AREA) {
useOfflineStore.setState((s) => ({ storageFull: { ...s.storageFull, [event.account]: s.storageFull[event.account] ?? Date.now() } }));
const now = Date.now();
if (now - lastStorageNotice < STORAGE_NOTICE_MS) return;
lastStorageNotice = now;
@@ -242,6 +309,9 @@ function noticeRefused(answered: OfflineResponse | null, methodCalls: OfflineMet
notify({ kind: emptied ? 'refused-delete' : 'refused-other' });
}
/** What the store may keep for later when it answers for the server. */
const QUEUEING_METHOD = /^(Email|CalendarEvent|Calendar|ContactCard|AddressBook)\/set$/;
const probes = new Map<string, Promise<boolean>>();
/** A black-holed connection never answers the probe either. */
const PROBE_TIMEOUT_MS = 5_000;
@@ -290,7 +360,7 @@ const hooks: OfflineHooks = {
// The store couldn't start or lost its owner: as if nothing were kept.
}
noticeRefused(answered, methodCalls);
if (methodCalls.some(([name]) => name === 'Email/set') && answered) {
if (answered && methodCalls.some(([name]) => QUEUEING_METHOD.test(name))) {
requestOutboxSync();
void refreshQueueState();
}
@@ -327,7 +397,11 @@ const hooks: OfflineHooks = {
if (!offlineActive(account) || !account) return;
for (const [name, result, callId] of response.methodResponses) {
if (name !== 'Email/get' || !Array.isArray(result?.list)) continue;
const opened = (result.list as Array<{ bodyValues?: Record<string, { isTruncated?: boolean }> }>).filter(hasWholeText);
// Past the organisation's cap a message isn't kept, however it got opened.
const cap = devicesForAccount(account).offlineMaxDays;
const now = Date.now();
const opened = (result.list as Array<{ bodyValues?: Record<string, { isTruncated?: boolean }>; receivedAt?: unknown }>)
.filter((email) => hasWholeText(email) && !olderThanCap(email.receivedAt, cap, now));
if (opened.length === 0) continue;
const args = methodCalls.find(([n, , id]) => id === callId && n === name)?.[1];
const jmap = (result.accountId as string) || (args?.accountId as string) || client.getAccountId();
@@ -543,13 +617,13 @@ export async function refreshQueueState(): Promise<void> {
// Nothing is kept for an account that hasn't it on; and opening the
// store would create it (on the sign-in page, after a sign-out).
if (visibleAccounts().length === 0) {
useOfflineStore.setState({ outbox: [], pendingChanges: 0, conflicts: [], localDrafts: [] });
useOfflineStore.setState({ outbox: [], pendingChanges: 0, conflicts: [], areaConflicts: [], localDrafts: [] });
return;
}
try {
// Sends of a tab closed on the way: uncertain, so they can be looked up.
await settleStaleSends();
const [{ failed, patches, outbox: entries, conflicts }, drafts] = await Promise.all([readQueue(), readLocalDrafts()]);
const [{ failed, patches, patchesBy, outbox: entries, conflicts }, drafts] = await Promise.all([readQueue(), readLocalDrafts()]);
if (failed.length > 0) noteStoreUnavailable();
const rows: OutboxRow[] = entries.map((entry) => ({
id: entry.id,
@@ -564,19 +638,90 @@ export async function refreshQueueState(): Promise<void> {
const shown = useOfflineStore.getState();
const unread = shown.outbox.filter((row) => failed.includes(row.account));
const unreadDrafts = shown.localDrafts.filter((row) => failed.includes(row.account));
useOfflineStore.setState({ outbox: [...unread, ...rows], pendingChanges: patches, conflicts, localDrafts: [...unreadDrafts, ...drafts] });
const accounts = visibleAccounts();
const areas = await Promise.all(accounts.map(readAreaState));
const changesBy = Object.fromEntries(accounts.map((account, index) => [account, (patchesBy[account] ?? 0) + areas[index].pending]));
useOfflineStore.setState((s) => ({
outbox: [...unread, ...rows],
pendingChanges: patches,
conflicts,
localDrafts: [...unreadDrafts, ...drafts],
areaConflicts: areas.flatMap((area) => area.conflicts),
// An account that couldn't answer counts with what it last said.
changesBy: { ...changesBy, ...Object.fromEntries(failed.map((account) => [account, s.changesBy[account] ?? 0])) },
}));
if (rows.some((row) => row.status === 'sending')) checkStaleSendsLater();
// What a browser clearing the store would lose. Counted here, and the
// count is all that's kept outside the store. A store that couldn't be
// read keeps its last count.
accounts.forEach((account, index) => {
if (failed.includes(account)) return;
const sends = rows.filter((row) => row.account === account).length + drafts.filter((row) => row.account === account).length;
writeHealth(account, { pending: sends + (patchesBy[account] ?? 0) + areas[index].pending });
});
} catch {
// The store is being deleted, or can't start.
noteStoreUnavailable();
}
}
interface StoredCalendarConflict { id: number; createdAt: number; title: string | null; reason: 'refused' | 'changedOnServer' | 'destroyedOnServer'; kind: string; description: string | null; errorType: string | null }
interface StoredContactConflict { id: number; createdAt: number; summary: string; kind: 'refused' | 'changed' | 'gone'; local: { op?: string }; error: string | null }
/** What the calendar and contacts have waiting or couldn't send. An area this build doesn't have counts as empty. */
async function readAreaState(account: string): Promise<{ conflicts: AreaConflict[]; pending: number }> {
const read = <T,>(name: string, none: T) => getStore().area<T | null | undefined>(account, name).then((value) => value ?? none, () => none);
const [calendar, contacts, calendarPending, contactsPending] = await Promise.all([
read<StoredCalendarConflict[]>('calendar.conflicts', []),
read<StoredContactConflict[]>('contacts.conflicts', []),
read<number>('calendar.pending', 0),
read<number>('contacts.pending', 0),
]);
const conflicts: AreaConflict[] = [
...calendar.map((row): AreaConflict => ({
area: 'calendar',
account,
id: row.id,
createdAt: row.createdAt,
title: row.title ?? '',
reason: row.reason === 'refused' ? 'refused' : row.reason === 'destroyedOnServer' ? 'gone' : 'changed',
destroy: row.kind === 'destroy',
detail: row.description ?? row.errorType,
})),
...contacts.map((row): AreaConflict => ({
area: 'contacts',
account,
id: row.id,
createdAt: row.createdAt,
title: row.summary,
reason: row.kind,
destroy: row.local?.op === 'destroy',
detail: row.error,
})),
];
return { conflicts, pending: calendarPending + contactsPending };
}
/** The user has seen it: the calendar's or the contacts' copy stays as the server has it. */
export async function dismissAreaConflict(conflict: AreaConflict): Promise<void> {
await getStore().area(conflict.account, `${conflict.area}.dismissConflict`, { id: conflict.id });
await refreshQueueState();
}
export async function refreshUsage(): Promise<void> {
if (!offlineSupported() || visibleAccounts().length === 0) return;
const accounts = visibleAccounts();
if (!offlineSupported() || accounts.length === 0) return;
try {
const usage = await Promise.all(visibleAccounts().map((account) => getStore().area<{ bytes: number }>(account, 'mail.usage')));
useOfflineStore.setState({ usageBytes: usage.reduce((sum, { bytes }) => sum + bytes, 0) });
const settled = await Promise.allSettled(accounts.map((account) => getStore().area<StoreUsage>(account, 'store.usage')));
// One account's store being in trouble says nothing about the others'.
useOfflineStore.setState((s) => {
const usage = { ...s.usage };
accounts.forEach((account, index) => {
const result = settled[index];
if (result.status === 'fulfilled' && result.value) usage[account] = result.value;
});
return { usage, usageBytes: accounts.reduce((sum, account) => sum + (usage[account]?.bytes ?? 0), 0) };
});
} catch {
/* deleted meanwhile */
}
@@ -599,6 +744,9 @@ export function scheduleSync(account: string, delayMs = 2_000): void {
const syncsRunning = new Set<string>();
export async function runSync(account: string): Promise<void> {
// Stopped by the device's OS policy (stores/auth-store.ts): its data waits.
const entry = useAccountStore.getState().accounts.find((candidate) => candidate.id === account);
if (entry && accountRefusedByDesktopPolicy(entry)) return;
if (syncsRunning.has(account) || !offlineActive(account)) return;
const client = onlineClientFor(account);
if (!client || isOfflineNow(client.getServerUrl())) return;
@@ -672,6 +820,7 @@ async function flushAreas(account: string, client: NonNullable<ReturnType<typeof
const tally = await getStore().area<{ sent?: number; conflicts?: number | unknown[] } | null>(account, call).catch(() => null);
if (!tally) continue;
const conflicts = Array.isArray(tally.conflicts) ? tally.conflicts.length : tally.conflicts ?? 0;
if (conflicts > 0) notify({ kind: 'conflict', count: conflicts });
if (!tally.sent && !conflicts) continue;
emitTypesChanged(client, types);
}
@@ -717,13 +866,12 @@ export async function flushAll(): Promise<void> {
export async function enableOffline(account: string): Promise<void> {
if (!offlineAllowed(account)) return;
const enabled = useOfflineStore.getState().enabled;
// Before the list: an account on the list without a choice is one from before the local store.
writeChoice(account, { on: true });
if (!enabled.includes(account)) writeEnabled([...enabled, account]);
// Ask the browser not to clear it when space runs low (it may say no).
try {
void navigator.storage?.persist?.().catch(() => false);
} catch {
/* no Storage API */
}
// Ask the browser not to clear it when space runs low. It may say no, which
// the settings page shows, with a way to ask again.
void requestPersistence();
await getStore().ensureOpen(account).catch(() => {});
await tellServiceWorker(true);
scheduleSync(account, 0);
@@ -733,10 +881,107 @@ export async function enableOffline(account: string): Promise<void> {
export async function disableOffline(account: string): Promise<void> {
confirmLoss(account);
clearHandoffs();
writeEnabled(useOfflineStore.getState().enabled.filter((id) => id !== account));
await wipeAccount(account);
disabling.add(account);
try {
writeEnabled(useOfflineStore.getState().enabled.filter((id) => id !== account));
await wipeAccount(account);
// Remembered where the app would turn it on by itself.
if (offlineDefaultOn()) writeChoice(account, { on: false });
} finally {
disabling.delete(account);
}
}
/** On their way off: the desktop default doesn't turn them back on meanwhile. */
const disabling = new Set<string>();
/**
* Removes everything kept for an account, unsent messages included (the user
* confirmed), and its key, and starts again from the server if it stays on.
*/
export async function clearAccount(account: string): Promise<void> {
confirmLoss(account);
const timer = syncTimers.get(account);
if (timer) clearTimeout(timer);
syncTimers.delete(account);
await nameLost(account, false);
named.delete(account);
try {
await getStore().remove(account);
} catch {
// No store: nothing to remove. The key is gone in any case.
}
useOfflineStore.setState((s) => ({ storeLost: s.storeLost.filter((id) => id !== account) }));
writeHealth(account, { pending: 0, reset: undefined, lastSync: undefined, clearedAt: Date.now() });
useOfflineStore.setState((s) => {
const { [account]: _usage, ...usage } = s.usage;
const { [account]: _full, ...storageFull } = s.storageFull;
const { [account]: _progress, ...progress } = s.progress;
const { [account]: _synced, ...lastSyncedAt } = s.lastSyncedAt;
return { usage, storageFull, progress, lastSyncedAt };
});
await refreshQueueState();
if (useOfflineStore.getState().enabled.includes(account)) {
await getStore().ensureOpen(account).catch(() => {});
scheduleSync(account, 0);
}
}
/** The user changed what an account keeps. Written first, so a reload holds to it. */
export function chooseScope(account: string, patch: UserChoice): void {
// Over what's in force: someone from before the local store has a choice nobody wrote down.
writeChoice(account, { ...userChoiceFor(account), ...patch });
// Arrowing through a list passes shorter windows on the way: those prune.
const waiting = scopeTimers.get(account);
if (waiting) clearTimeout(waiting);
scopeTimers.set(account, setTimeout(() => {
scopeTimers.delete(account);
pushScope(account);
}, SCOPE_SETTLE_MS));
}
/** How long a choice has to stay before the store acts on it. */
const SCOPE_SETTLE_MS = 800;
const scopeTimers = new Map<string, ReturnType<typeof setTimeout>>();
/** The store's scope follows the page's answer. Nothing happens in the store if it's the same as before. */
function pushScope(account: string): void {
if (!visibleAccounts().includes(account) || !scopeDecided(account) || !offlineActive(account)) return;
const scope: StoreScope = scopeForAccount(account).scope;
void getStore().setScope(account, scope).then(() => refreshUsage()).catch(() => {});
}
/** The notice about the desktop default was shown on this device. Not account data, so it outlives a sign-out. */
const DEFAULT_ON_NOTICE_KEY = 'bulwark-offline-default-notice';
/**
* The desktop app keeps mail for every account without asking, unless the
* organisation forbids it or the user turned it off. Says so once.
*/
function applyDefaultOn(): void {
if (!offlineDefaultOn() || !offlineSupported()) return;
for (const account of signedInAccountIds()) {
if (useOfflineStore.getState().enabled.includes(account) || disabling.has(account) || !scopeDecided(account)) continue;
if (!scopeForAccount(account).enabled) continue;
void enableOffline(account);
try {
if (localStorage.getItem(DEFAULT_ON_NOTICE_KEY)) continue;
localStorage.setItem(DEFAULT_ON_NOTICE_KEY, '1');
} catch {
continue;
}
notify({ kind: 'default-on' });
}
}
/** The warning that the store came back empty was read. */
export function dismissStoreReset(account: string): void {
useOfflineStore.setState((s) => ({ storeLost: s.storeLost.filter((id) => id !== account) }));
writeHealth(account, { reset: undefined });
}
export { readPersistence, requestPersistence, safariMayDelete };
// ---- Unsent mail a wipe deletes ----
// The user agreed to lose unsent mail (the sign-out question, switching
@@ -774,7 +1019,7 @@ const UNSENT_READ_MS = 2_000;
*/
export async function unsentCount(scope: string, waitMs = UNSENT_READ_MS): Promise<{ messages: number; changes: number }> {
const shown = useOfflineStore.getState();
const known = { messages: [...shown.outbox, ...shown.localDrafts].filter((row) => scope === 'all' || row.account === scope).length, changes: shown.pendingChanges };
const known = { messages: [...shown.outbox, ...shown.localDrafts].filter((row) => scope === 'all' || row.account === scope).length, changes: scope === 'all' ? Object.values(shown.changesBy).reduce((sum, count) => sum + count, 0) : shown.changesBy[scope] ?? 0 };
const accounts = (scope === 'all' ? visibleAccounts() : [scope]).filter((account) => shown.enabled.includes(account));
if (accounts.length === 0) return known;
const pendingIn = (account: string, area: string) => getStore().area<number>(account, `${area}.pending`).catch(() => 0);
@@ -823,6 +1068,7 @@ export async function wipeAccount(account: string): Promise<void> {
if (useOfflineStore.getState().enabled.includes(account)) {
writeEnabled(useOfflineStore.getState().enabled.filter((id) => id !== account));
}
forgetAccountState(account);
// Nobody keeps mail here any more: everything goes (and isn't opened
// again, which would create it anew).
if (useOfflineStore.getState().enabled.length === 0) {
@@ -843,6 +1089,8 @@ export async function wipeEverything(): Promise<void> {
for (const timer of syncTimers.values()) clearTimeout(timer);
syncTimers.clear();
writeEnabled([]);
// Turned off by hand and still signed in: that stays their word.
forgetAllAccountState(signedInAccountIds());
getStore().stop();
await purgeStoreFiles();
try {
@@ -855,7 +1103,14 @@ export async function wipeEverything(): Promise<void> {
clearHandoffs();
confirmed.clear();
named.clear();
useOfflineStore.setState({ outbox: [], pendingChanges: 0, conflicts: [], localDrafts: [], usageBytes: 0, syncing: [], lastSyncedAt: {} });
useOfflineStore.setState({ outbox: [], pendingChanges: 0, conflicts: [], areaConflicts: [], localDrafts: [], usageBytes: 0, syncing: [], lastSyncedAt: {} });
}
/** The organisation's word on this account, from the server or the last fetch. Defaults aren't its word. */
function scopeDecided(account: string): boolean {
const { loaded, source, partial } = usePolicyStore.getState();
// The public part a page gets before it's signed in has no caps and no scopes in it.
return loaded && !partial && (source === 'server' || source === 'cache') && policyDecided(account);
}
/** Whether the policy of an account is known well enough to decide on it. */
@@ -863,7 +1118,13 @@ function policyDecided(account: string): boolean {
// Dropped, with no groups to wait for: what applies to everyone reaches it.
if (isEvicted(account)) return true;
const auth = useAuthStore.getState();
if (account === auth.activeAccountId || !auth.activeAccountId) return !usePolicyStore.getState().subjectPending;
if (account === auth.activeAccountId || !auth.activeAccountId) {
const { subject, subjectPending } = usePolicyStore.getState();
if (subjectPending) return false;
// The active account's id is kept across a reload and its subject isn't: until
// it's there only Everyone's values show, and a group scope may say otherwise.
return !auth.activeAccountId || subject !== null || !(auth.isLoading || auth.client);
}
// Another signed-in account: its groups have to be known first (a group
// scope may forbid it); a signed-out one is wiped with its sign-out.
if (!auth.getClientForAccount?.(account)) return false;
@@ -878,14 +1139,18 @@ export function enforcePolicy(): void {
if (!policyDecided(account)) continue;
// What the organisation says, not whether this window can open the store:
// a blocked store says nothing against the data in it.
if (devicesForAccount(account).noOffline) {
if (policyOfKept(account).noOffline) {
// Whoever is here now wasn't signed in to a dropped account.
const someoneElses = isEvicted(account);
clearHandoffs();
void wipeAccount(account);
if (!someoneElses) notify({ kind: 'forbidden' });
continue;
}
// A lowered cap prunes what's kept, and a raised one starts fetching.
pushScope(account);
}
applyDefaultOn();
}
// ---- Started without the server ----
@@ -981,6 +1246,20 @@ export function startOfflineMode(): () => void {
// there): stop here too, and don't write anything for that account. One
// switched on there has to be opened here if this tab owns the store.
const onStorage = (event: StorageEvent) => {
if (event.key === OFFLINE_SCOPE_KEY || event.key === null) useOfflineStore.setState({ choices: readStoredChoices() });
if (event.key === OFFLINE_HEALTH_KEY || event.key === null) {
const before = useOfflineStore.getState().health;
const health = readStoredHealth();
useOfflineStore.setState({ health });
// Another tab cleared an account that stays on: if this tab owns the store, it opens the new one.
for (const id of useOfflineStore.getState().enabled) {
if (health[id]?.clearedAt && health[id].clearedAt !== before[id]?.clearedAt) {
void getStore().ensureOpen(id).catch(() => {});
void refreshQueueState();
void refreshUsage();
}
}
}
if (event.key !== OFFLINE_ACCOUNTS_KEY && event.key !== null) return;
const next = readEnabledFrom(event.key === null ? null : event.newValue);
const before = useOfflineStore.getState().enabled;
@@ -1019,6 +1298,7 @@ export function startOfflineMode(): () => void {
// the flush at the start may have run before it was connected.
const flushedFor = new WeakSet<object>();
const syncSignedIn = () => {
applyDefaultOn();
let connected = false;
for (const account of useOfflineStore.getState().enabled) {
const client = onlineClientFor(account);
@@ -1053,7 +1333,16 @@ export function startOfflineMode(): () => void {
staleCheck = null;
});
if (useOfflineStore.getState().enabled.length > 0) void tellServiceWorker(true);
if (useOfflineStore.getState().enabled.length > 0) {
void tellServiceWorker(true);
void readPersistence().then((state) => {
if (state === 'refused' && isStandaloneDisplayMode()) void requestPersistence();
});
}
// An installed app is usually told yes where the tab was told no.
const onInstalled = () => { if (useOfflineStore.getState().enabled.length > 0) void requestPersistence(); };
window.addEventListener('appinstalled', onInstalled);
cleanups.push(() => window.removeEventListener('appinstalled', onInstalled));
void (async () => {
await moveLegacyOutbox();
await refreshQueueState();
@@ -1126,7 +1415,13 @@ onFullSignOut(({ keepLocalStore }) => {
if (!keepLocalStore) getStore().stop();
confirmed.clear();
named.clear();
useOfflineStore.setState((s) => ({ enabled: keepLocalStore ? s.enabled : [], outbox: [], pendingChanges: 0, conflicts: [], localDrafts: [], usageBytes: 0, syncing: [], lastSyncedAt: {}, startedOffline: false }));
useOfflineStore.setState((s) => ({
enabled: keepLocalStore ? s.enabled : [],
choices: keepLocalStore ? s.choices : {},
health: keepLocalStore ? s.health : {},
outbox: [], pendingChanges: 0, conflicts: [], areaConflicts: [], localDrafts: [], usageBytes: 0, usage: {},
storageFull: {}, progress: {}, syncing: [], lastSyncedAt: {}, startedOffline: false,
}));
});
// Every sign-out the user didn't agree to lose mail at (the session limits,
+2 -1
View File
@@ -40,7 +40,7 @@ interface StoredQueue {
}
/** Everything waiting for one account (or every account with it on), oldest first. */
export async function readQueue(account?: string): Promise<{ failed: string[]; patches: number; outbox: OutboxEntry[]; conflicts: ConflictEntry[] }> {
export async function readQueue(account?: string): Promise<{ failed: string[]; patches: number; patchesBy: Record<string, number>; outbox: OutboxEntry[]; conflicts: ConflictEntry[] }> {
const accounts = account ? [account] : visibleAccounts();
const settled = await Promise.allSettled(accounts.map(async (id) => ({ id, queue: await getStore().area<StoredQueue>(id, 'outbox.read') })));
const read = settled.flatMap((result) => (result.status === 'fulfilled' ? [result.value] : []));
@@ -50,6 +50,7 @@ export async function readQueue(account?: string): Promise<{ failed: string[]; p
return {
failed,
patches: read.reduce((sum, { queue }) => sum + queue.patches, 0),
patchesBy: Object.fromEntries(read.map(({ id, queue }) => [id, queue.patches])),
outbox: read.flatMap(({ id, queue }) => queue.entries.map((entry) => ({ ...entry, account: id }))),
conflicts: read.flatMap(({ id, queue }) => queue.conflicts.map((conflict) => ({
id: conflict.id,
+156
View File
@@ -0,0 +1,156 @@
/**
* What an account keeps on this device. Three sources, in this order: the
* organisation's policy, the user's choice, the platform's default. The policy
* only ever lowers: nothing below it can raise a cap or turn back on what it
* forbids, and that includes the desktop app's default of keeping mail.
*/
import { getDesktopBridge, isDesktopApp } from '@/lib/desktop/bridge';
import { DEFAULT_SCOPE, type StoreScope } from '@/lib/store/area';
import type { DevicePolicy } from '@/lib/device-policy';
import { useOfflineStore, type UserChoice } from './state';
const MB = 1024 * 1024;
/** The windows a user picks from. Null keeps everything. */
export const WINDOW_CHOICES: readonly (number | null)[] = [30, 90, 365, null];
/** The largest attachment a user may keep, in MB. */
export const ATTACHMENT_CAP_CHOICES: readonly number[] = [5, 25, 100, 500];
/** What the old offline mode kept, for those who had it on before the local store. */
export const LEGACY_CHOICE: UserChoice = { on: true, days: 30, attachments: true, maxAttachmentBytes: 5 * MB };
export interface PlatformDefault {
on: boolean;
/** The OS policy says so either way (desktop `OfflineEnabled`, forced): no user choice applies. */
forced?: boolean | null;
scope: StoreScope;
}
export interface EffectiveScope {
account: string;
/** Whether the account keeps mail here at all. */
enabled: boolean;
scope: StoreScope;
/** What the policy decides, for the settings page: these show "Set by your organisation". */
locked: {
offline: boolean;
/** The switch itself: forced on or off by the OS policy. */
forced: boolean;
days: boolean;
attachments: boolean;
maxAttachment: boolean;
maxTotal: boolean;
};
}
interface OfflineEnabledSetting {
value?: unknown;
locked?: unknown;
source?: unknown;
}
/** `OfflineEnabled` from the desktop app's OS policies. Feature-detected: older shells have no such member. */
function offlineEnabledPolicy(): OfflineEnabledSetting | null {
const bridge = getDesktopBridge() as { policy?: { get?: () => { OfflineEnabled?: OfflineEnabledSetting } | null | undefined } } | null;
try {
return bridge?.policy?.get?.()?.OfflineEnabled ?? null;
} catch {
return null;
}
}
/**
* The desktop app keeps mail without asking, unless a recommended policy says
* otherwise. Browsers stay opt-in. Feature-detected: the bridge is only there in the app.
*/
export function offlineDefaultOn(): boolean {
if (!isDesktopApp()) return false;
const setting = offlineEnabledPolicy();
if (setting && setting.source !== 'default' && typeof setting.value === 'boolean') return setting.value;
return true;
}
export function platformDefault(): PlatformDefault {
const setting = offlineEnabledPolicy();
const forced = setting?.locked && typeof setting.value === 'boolean' ? setting.value : null;
return { on: offlineDefaultOn(), forced, scope: DEFAULT_SCOPE };
}
/** Null means everything, so any cap is lower. */
function cappedDays(days: number | null, cap: number): number | null {
if (!(cap > 0)) return days;
return days === null ? cap : Math.min(days, cap);
}
function cappedBytes(bytes: number, capMb: number): number {
return capMb > 0 ? Math.min(bytes, capMb * MB) : bytes;
}
export function effectiveScope(
account: string,
userChoice: UserChoice | null | undefined,
policy: Pick<DevicePolicy, 'noOffline' | 'offlineMaxDays' | 'noOfflineAttachments' | 'offlineMaxAttachmentMb' | 'offlineMaxTotalMb'>,
defaults: PlatformDefault,
): EffectiveScope {
const choice = userChoice ?? {};
const days = choice.days !== undefined ? choice.days : defaults.scope.days;
const maxAttachment = choice.maxAttachmentBytes ?? defaults.scope.maxAttachmentBytes;
return {
account,
enabled: !policy.noOffline && (defaults.forced ?? choice.on ?? defaults.on),
scope: {
days: cappedDays(days, policy.offlineMaxDays),
attachments: !policy.noOfflineAttachments && (choice.attachments ?? defaults.scope.attachments),
maxAttachmentBytes: cappedBytes(maxAttachment, policy.offlineMaxAttachmentMb),
maxTotalBytes: cappedBytes(defaults.scope.maxTotalBytes, policy.offlineMaxTotalMb),
orgMaxFileBytes: policy.offlineMaxAttachmentMb > 0 ? policy.offlineMaxAttachmentMb * MB : null,
},
locked: {
offline: policy.noOffline,
forced: typeof defaults.forced === 'boolean',
days: policy.offlineMaxDays > 0,
attachments: policy.noOfflineAttachments,
maxAttachment: policy.offlineMaxAttachmentMb > 0,
maxTotal: policy.offlineMaxTotalMb > 0,
},
};
}
/**
* What the user chose for an account. The switch used to be the only thing
* stored, so an account that has it on and no choice yet was turned on under
* the old offline mode, and keeps the window it had.
*/
export function userChoiceFor(account: string): UserChoice | null {
const { choices, enabled } = useOfflineStore.getState();
const stored = choices[account];
const on = enabled.includes(account);
if (!stored) return on ? LEGACY_CHOICE : null;
return on && stored.on === undefined ? { ...stored, on } : stored;
}
/** The windows on offer under a cap: the ones that fit, and the cap itself when it falls between them. */
export function windowOptions(capDays: number): (number | null)[] {
if (!(capDays > 0)) return [...WINDOW_CHOICES];
const fit = WINDOW_CHOICES.filter((days): days is number => days !== null && days < capDays);
return [...fit, capDays];
}
/** The attachment sizes on offer under a cap, in MB. */
export function attachmentCapOptions(capMb: number): number[] {
if (!(capMb > 0)) return [...ATTACHMENT_CAP_CHOICES];
const fit = ATTACHMENT_CAP_CHOICES.filter((mb) => mb < capMb);
return [...fit, capMb];
}
export function maxAttachmentMb(scope: StoreScope): number {
return Math.round(scope.maxAttachmentBytes / MB);
}
/** A message older than the organisation's cap isn't kept, opened or not. */
export function olderThanCap(receivedAt: unknown, capDays: number, now: number): boolean {
if (!(capDays > 0) || typeof receivedAt !== 'string') return false;
const at = Date.parse(receivedAt);
return Number.isFinite(at) && at < now - capDays * 86_400_000;
}
+122 -5
View File
@@ -7,11 +7,46 @@
*/
import { create } from 'zustand';
import { DEFAULT_SCOPE, type StoreScope } from '@/lib/store/area';
import { DEFAULT_SCOPE, type StoreScope, type StoreUsage } from '@/lib/store/area';
import type { ConflictEntry, LocalDraftRow, OutboxEntry, OutboxError } from './queue';
import type { PersistState } from './storage-health';
import type { SyncProgress } from './sync-progress';
/** localStorage: the local account ids that keep mail on this device. */
export const OFFLINE_ACCOUNTS_KEY = 'bulwark-offline-accounts';
/** localStorage: what each account chose to keep offline, on top of the defaults. Numbers and switches only. */
export const OFFLINE_SCOPE_KEY = 'bulwark-offline-scope';
/** localStorage: what's known about each account's store with the store gone. Numbers only, never content. */
export const OFFLINE_HEALTH_KEY = 'bulwark-offline-health';
export interface UserChoice extends Partial<Pick<StoreScope, 'days' | 'attachments' | 'maxAttachmentBytes'>> {
/** Off here means the user turned it off: the desktop app's default doesn't turn it back on. */
on?: boolean;
}
export interface AccountHealth {
/** Unsent messages and changes last counted: what an eviction loses. */
pending?: number;
/** When the last sync finished. Survives a reload, unlike the running state. */
lastSync?: number;
/** The store came back empty (see `storeLost`). Unlike that, this stays across a reload until the user has read it. */
reset?: { at: number; lost: number };
/** The user cleared the account: another tab that owns the store opens the new one. */
clearedAt?: number;
}
/** A calendar or contact edit that didn't go through. Lives in its own area's store. */
export interface AreaConflict {
area: 'calendar' | 'contacts';
account: string;
id: number;
createdAt: number;
title: string;
reason: 'refused' | 'changed' | 'gone';
/** A delete can't be taken back, so it reads differently. */
destroy: boolean;
detail: string | null;
}
export interface OutboxRow {
id: number;
@@ -43,11 +78,24 @@ interface OfflineState {
pendingChanges: number;
/** Changes the server refused, or that met a message changed on another device. */
conflicts: ConflictEntry[];
/** The same for calendar events and contacts. */
areaConflicts: AreaConflict[];
/** Changes waiting, per account: mail's, the calendar's and the contacts'. */
changesBy: Record<string, number>;
/** Drafts that are only on this device: on their way up, or refused by the server. */
localDrafts: LocalDraftRow[];
/** A local draft the user asked to open, for the mail page to pick up. */
draftToOpen: { account: string; jmap: string; id: string } | null;
usageBytes: number;
usage: Record<string, StoreUsage>;
choices: Record<string, UserChoice>;
health: Record<string, AccountHealth>;
/** What the browser said about keeping this site's data. */
persist: PersistState;
/** Accounts that couldn't write, and since when. Gone after the next good sync. */
storageFull: Record<string, number>;
/** How far the sync running for an account got. */
progress: Record<string, SyncProgress>;
/** Accounts whose mail is being brought onto the device right now. */
syncing: string[];
lastSyncedAt: Record<string, number>;
@@ -63,6 +111,25 @@ function readEnabled(): string[] {
}
}
function readRecord<T>(key: string): Record<string, T> {
try {
const raw = typeof localStorage === 'undefined' ? null : localStorage.getItem(key);
const parsed = raw ? JSON.parse(raw) : null;
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record<string, T> : {};
} catch {
return {};
}
}
function writeRecord(key: string, value: Record<string, unknown>): void {
try {
if (Object.keys(value).length > 0) localStorage.setItem(key, JSON.stringify(value));
else localStorage.removeItem(key);
} catch {
/* storage unavailable */
}
}
export const useOfflineStore = create<OfflineState>()(() => ({
enabled: readEnabled(),
browserOffline: typeof navigator !== 'undefined' && navigator.onLine === false,
@@ -73,13 +140,65 @@ export const useOfflineStore = create<OfflineState>()(() => ({
outbox: [],
pendingChanges: 0,
conflicts: [],
areaConflicts: [],
changesBy: {},
localDrafts: [],
draftToOpen: null,
usageBytes: 0,
usage: {},
choices: readRecord<UserChoice>(OFFLINE_SCOPE_KEY),
health: readRecord<AccountHealth>(OFFLINE_HEALTH_KEY),
persist: 'unknown',
storageFull: {},
progress: {},
syncing: [],
lastSyncedAt: {},
}));
export function writeChoice(account: string, patch: UserChoice): void {
const choices = { ...useOfflineStore.getState().choices, [account]: { ...useOfflineStore.getState().choices[account], ...patch } };
useOfflineStore.setState({ choices });
writeRecord(OFFLINE_SCOPE_KEY, choices);
}
export function forgetAccountState(account: string): void {
const { choices, health, usage, storageFull, progress } = useOfflineStore.getState();
const without = <T,>(record: Record<string, T>) => Object.fromEntries(Object.entries(record).filter(([id]) => id !== account));
const next = { choices: without(choices), health: without(health), usage: without(usage), storageFull: without(storageFull), progress: without(progress) };
useOfflineStore.setState(next);
writeRecord(OFFLINE_SCOPE_KEY, next.choices);
writeRecord(OFFLINE_HEALTH_KEY, next.health);
}
export function writeHealth(account: string, patch: AccountHealth): void {
const { health } = useOfflineStore.getState();
const current = health[account];
if (current && Object.entries(patch).every(([key, value]) => current[key as keyof AccountHealth] === value)) return;
const next = { ...health, [account]: { ...current, ...patch } };
useOfflineStore.setState({ health: next });
writeRecord(OFFLINE_HEALTH_KEY, next);
}
/** The state of other tabs, after a storage event. */
export function readStoredChoices(): Record<string, UserChoice> {
return readRecord<UserChoice>(OFFLINE_SCOPE_KEY);
}
export function readStoredHealth(): Record<string, AccountHealth> {
return readRecord<AccountHealth>(OFFLINE_HEALTH_KEY);
}
/** Nothing is kept for anyone any more. Those still signed in stay off if they turned it off. */
export function forgetAllAccountState(signedIn: readonly string[] = []): void {
const off: Record<string, UserChoice> = {};
for (const [account, choice] of Object.entries(useOfflineStore.getState().choices)) {
if (choice.on === false && signedIn.includes(account)) off[account] = { on: false };
}
useOfflineStore.setState({ choices: off, health: {}, usage: {}, storageFull: {}, progress: {}, areaConflicts: [], changesBy: {} });
writeRecord(OFFLINE_SCOPE_KEY, off);
writeRecord(OFFLINE_HEALTH_KEY, {});
}
export function writeEnabled(enabled: string[]): void {
useOfflineStore.setState({ enabled });
try {
@@ -90,12 +209,10 @@ export function writeEnabled(enabled: string[]): void {
}
}
/** localStorage: what each account chose to keep offline, on top of the defaults. */
export const OFFLINE_SCOPE_KEY = 'bulwark-offline-scope';
/** What the user chose, over the defaults. The organisation's caps come on top: scopeForAccount in offline-mode.ts. */
export function offlineScopeFor(account: string): StoreScope {
try {
const chosen = (JSON.parse(localStorage.getItem(OFFLINE_SCOPE_KEY) ?? '{}') as Record<string, Partial<StoreScope>>)[account];
const { on: _on, ...chosen } = (JSON.parse(localStorage.getItem(OFFLINE_SCOPE_KEY) ?? '{}') as Record<string, UserChoice>)[account] ?? {};
return { ...DEFAULT_SCOPE, ...chosen };
} catch {
return DEFAULT_SCOPE;
+50
View File
@@ -0,0 +1,50 @@
/**
* The browser's side of keeping mail: whether it promised not to clear it, and
* whether it did anyway. Nothing here holds account content.
*/
import { isStandaloneDisplayMode } from '@/lib/standalone-display';
import { useOfflineStore } from './state';
export type PersistState = 'unknown' | 'granted' | 'refused' | 'unsupported';
/**
* Asks the browser not to clear this site's data when space runs low, and
* remembers the answer for the settings page. Chromium decides by itself and
* may say no until the app is installed; Firefox asks the user.
*/
export async function requestPersistence(): Promise<PersistState> {
let state: PersistState = 'unsupported';
try {
if (typeof navigator.storage?.persist === 'function') state = (await navigator.storage.persist()) ? 'granted' : 'refused';
} catch {
state = 'refused';
}
useOfflineStore.setState({ persist: state });
return state;
}
/** What the browser last said, without asking. */
export async function readPersistence(): Promise<PersistState> {
let state: PersistState = 'unsupported';
try {
if (typeof navigator.storage?.persisted === 'function') state = (await navigator.storage.persisted()) ? 'granted' : 'refused';
} catch {
state = 'unknown';
}
useOfflineStore.setState({ persist: state });
return state;
}
/**
* Safari deletes a site's data after 7 days without a visit, unless the site is
* on the home screen or in the Dock. On iPhone and iPad every browser runs on
* the same engine, so Chrome and Firefox there count too. Chromium on a Mac or
* Windows, and the desktop app, say "Chrome" in the user agent.
*/
export function safariMayDelete(userAgent: string = typeof navigator === 'undefined' ? '' : navigator.userAgent, installed: boolean = isStandaloneDisplayMode()): boolean {
if (installed) return false;
if (!/AppleWebKit/.test(userAgent) || !/Version\/|CriOS|FxiOS|EdgiOS|OPiOS/.test(userAgent)) return false;
if (/iPhone|iPad|iPod/.test(userAgent)) return true;
return /Macintosh/.test(userAgent) && /Safari\//.test(userAgent) && !/Chrome|Chromium|Edg\/|OPR\//.test(userAgent);
}
+62
View File
@@ -0,0 +1,62 @@
/**
* How far a first sync got, and how long it may still take. The store reports
* batches of a phase: messages listed, then threads, then bodies, then files.
*/
export interface SyncProgress {
area: string;
phase: string;
done: number;
total: number | null;
/** When this phase started here, and how far it was then. */
startedAt: number;
startedDone: number;
at: number;
}
/**
* Listing a message is about ten times as fast as fetching its body and
* indexing it (bench rig, 50,000 messages: 35 s to list, 400 s in all). So
* while listing, the time left includes the bodies that come after it.
*/
const BODY_COST_PER_LIST_ITEM = 10;
/** An estimate from less than this jumps about too much to show. */
const MIN_SAMPLE_MS = 5_000;
/** A pause this long (asleep, offline) says nothing about the rate: the sample starts again. */
const SAMPLE_GAP_MS = 60_000;
/** Too small to be worth a progress line: new mail arriving in a normal pass. */
const MIN_ITEMS_SHOWN = 50;
export function nextProgress(
previous: SyncProgress | undefined,
event: { area: string; phase: string; done: number; total: number | null },
now: number,
): SyncProgress {
const samePhase = previous?.area === event.area && previous.phase === event.phase && event.done >= previous.done && now - previous.at < SAMPLE_GAP_MS;
return {
area: event.area,
phase: event.phase,
done: event.done,
total: event.total,
startedAt: samePhase ? previous.startedAt : now,
startedDone: samePhase ? previous.startedDone : event.done,
at: now,
};
}
export function progressWorthShowing(progress: SyncProgress | undefined): progress is SyncProgress {
return !!progress && (progress.total === null || progress.total >= MIN_ITEMS_SHOWN) && progress.done >= 0;
}
/** Minutes left, rounded up; null while there's nothing sensible to say. */
export function minutesLeft(progress: SyncProgress): number | null {
const { total, done, startedAt, startedDone, at } = progress;
if (total === null || total <= done) return null;
const elapsed = at - startedAt;
const moved = done - startedDone;
if (elapsed < MIN_SAMPLE_MS || moved <= 0) return null;
const perItem = elapsed / moved;
let left = (total - done) * perItem;
if (progress.phase === 'list') left += total * perItem * BODY_COST_PER_LIST_ITEM;
return Math.max(1, Math.ceil(left / 60_000));
}
+2
View File
@@ -15,6 +15,7 @@ import type { HostFeaturesView, InstalledPlugin } from '../plugin-types';
import type { OrganisationPolicy } from '@/lib/admin/organisation-policy';
import { assistantFeatures } from './assistant';
import { keystoreFeature } from './host-api-keystore';
import { pluginHasPermission } from './permissions';
/** The external-mail-warning plugin, retired in favour of organisation.externalMail. */
export const EXTERNAL_MAIL_WARNING_PLUGIN_ID = 'external-mail-warning';
@@ -34,6 +35,7 @@ export function snapshotHostFeatures(plugin?: InstalledPlugin): HostFeaturesView
// The AI assistant's host APIs this webmail offers (lib/plugin-sandbox/assistant).
...assistantFeatures(plugin),
...keystoreFeature(plugin),
...(plugin && pluginHasPermission(plugin, 'email:render-takeover') ? { blockExternalContent: true } : {}),
};
}
+7
View File
@@ -39,6 +39,7 @@ import { hookArgsFor } from './hook-payload';
import { rememberDeclaredHooks } from '../plugin-send-guards';
import type { SlotName } from '../plugin-types';
import { PLUGIN_REFUSED_ERROR, pluginAllowed } from '../outgoing-hosts';
import { PLUGIN_POLICY_REFUSED_ERROR, pluginMayRunByDesktopPolicy } from '../desktop/policy';
// ─── Hook-bus lookup (one flat map for name → bus) ────────────
@@ -229,6 +230,12 @@ export function loadSandboxedPlugin(plugin: InstalledPlugin): Promise<void> {
}
async function startSandboxedPlugin(plugin: InstalledPlugin): Promise<void> {
// The device's OS policy. On an instance this is the only gate there is: the
// instance's server knows nothing of this device's policy.
if (!pluginMayRunByDesktopPolicy(plugin)) {
storeAccessor?.setPluginStatus(plugin.id, 'error', PLUGIN_POLICY_REFUSED_ERROR);
return;
}
// A plugin that declares network access doesn't start while third-party
// traffic is off; the rest run (#903).
if (!pluginAllowed(plugin)) {
+1
View File
@@ -53,6 +53,7 @@ export interface DownSendGuard {
/** Errors that mean Bulwark refused to start the plugin, not that it broke. */
const REFUSALS = new Set([
'third-party-off',
'device-policy',
'user-plugins-off',
'approval-unavailable',
'approval-denied',
+6
View File
@@ -986,6 +986,12 @@ export interface HostFeaturesView extends AssistantHostFeatures {
* Absent otherwise. See `api.keystore`.
*/
keystore?: boolean;
/**
* onRenderEmailBody may return `blockExternalContent: true` to keep external content
* off for that body, whatever the settings and trusted senders allow. Per plugin, for
* those with `email:render-takeover`. Absent on webmails that ignore the field.
*/
blockExternalContent?: boolean;
}
/** One setting `api.app.searchSettings` found, in the user's language. */
+4 -1
View File
@@ -40,9 +40,12 @@ const SIGNED_OUT_STORAGE_KEYS = [
// Which accounts' data waits for a new sign-in, and until when.
'bulwark-offline-evicted',
'bulwark-store-opened',
// What each account chose to keep, and what's known about its store.
'bulwark-offline-scope',
'bulwark-offline-health',
];
const LOCAL_STORE_KEYS = new Set(['bulwark-offline-accounts', 'bulwark-offline-evicted', 'bulwark-store-opened']);
const LOCAL_STORE_KEYS = new Set(['bulwark-offline-accounts', 'bulwark-offline-evicted', 'bulwark-store-opened', 'bulwark-offline-scope', 'bulwark-offline-health']);
const SIGNED_OUT_SIGNAL = 'bulwark:signed-out';
export { SIGNED_OUT_SIGNAL as SIGNED_OUT_KEY };
+1 -1
View File
@@ -22,7 +22,7 @@ function setup(version: StalwartVersion = '1.0', server = new FakeCalendarJmap(v
session,
transport: server,
areas: [calendarArea],
scope: DEFAULT_SCOPE,
scope: { ...DEFAULT_SCOPE, days: null },
emit: (event) => events.push(event),
now: () => NOW,
sleep: async () => {},
+136
View File
@@ -0,0 +1,136 @@
// @vitest-environment node
/**
* The real JMAPClient reading the store's answers: what the client's own code
* makes of them is the contract, not the store's idea of the shape.
*/
import { afterEach, describe, expect, it } from 'vitest';
import { JMAPClient } from '@/lib/jmap/client';
import { setOfflineHooks, type OfflineHooks } from '@/lib/jmap/offline-hooks';
import { partialAccounts } from '@/lib/jmap/partial-accounts';
import type { AddressBook, ContactCard } from '@/lib/jmap/types';
import { DEFAULT_SCOPE } from '../area';
import { contactsArea } from '../contacts';
import { AccountEngine } from '../engine/account-engine';
import { JMAP_ACCOUNT } from './helpers/fake-jmap';
import { CONTACTS_SESSION, FakeContacts, SHARED_ACCOUNT } from './helpers/fake-contacts';
import { openMemoryStore } from './helpers/node-adapter';
afterEach(() => setOfflineHooks(null));
async function offlineClient(server: FakeContacts): Promise<{ client: JMAPClient; engine: AccountEngine }> {
const engine = new AccountEngine({
account: 'a@fake.test',
db: openMemoryStore(),
session: CONTACTS_SESSION,
transport: server,
areas: [contactsArea],
scope: DEFAULT_SCOPE,
sleep: async () => {},
});
await engine.requestSync();
engine.setOnline(false);
const hooks: Partial<OfflineHooks> = {
skipNetwork: () => true,
answer: (_client, calls) => engine.answer(calls),
record: () => {},
noteNetwork: () => {},
};
setOfflineHooks(hooks as OfflineHooks);
const client = JMAPClient.fromOfflineSession('https://fake.test', 'a@example.org', {
...CONTACTS_SESSION,
capabilities: { ...CONTACTS_SESSION.capabilities, 'urn:ietf:params:jmap:core': { maxObjectsInGet: 2 } },
});
return { client, engine };
}
function seeded(): FakeContacts {
const server = new FakeContacts();
for (const full of ['Anna Bauer', 'Bernd Graf', 'Carla Müller']) server.addCard({ name: { full } });
server.addBook({ id: 'teambook', name: 'Team' }, SHARED_ACCOUNT);
server.addCard({ id: 'c1', name: { full: 'Dora Team' }, addressBookIds: { teambook: true } }, SHARED_ACCOUNT);
return server;
}
describe('the client over the store', () => {
it('reports a store that has nothing yet as a failure, not as no contacts', async () => {
const engine = new AccountEngine({
account: 'a@fake.test', db: openMemoryStore(), session: CONTACTS_SESSION, transport: new FakeContacts(),
areas: [contactsArea], scope: DEFAULT_SCOPE, sleep: async () => {},
});
// Answered per call, with one more call the store can answer: the error isn't hidden by a null answer.
const perCall: Partial<OfflineHooks> = {
skipNetwork: () => true,
answer: async (_client, calls) => (await engine.answer(calls)) ?? {
methodResponses: calls.map(([, , id]) => ['error', { type: 'serverUnavailable' }, id]),
},
record: () => {},
noteNetwork: () => {},
};
setOfflineHooks(perCall as OfflineHooks);
const client = JMAPClient.fromOfflineSession('https://fake.test', 'a@example.org', CONTACTS_SESSION);
await expect(client.getAllContacts()).rejects.toThrow();
await expect(client.getAllAddressBooks()).rejects.toThrow();
});
it('lists all contacts and books across accounts, paging in its own batch size', async () => {
const { client } = await offlineClient(seeded());
const contacts = await client.getAllContacts();
expect(contacts.map((c) => c.name?.full).sort()).toEqual(['Anna Bauer', 'Bernd Graf', 'Carla Müller', 'Dora Team']);
const shared = contacts.find((c) => c.name?.full === 'Dora Team');
expect(shared).toMatchObject({ id: `${SHARED_ACCOUNT}:c1`, originalId: 'c1', isShared: true });
const books = await client.getAllAddressBooks();
expect(books.map((b) => b.id).sort()).toEqual(['book1', `${SHARED_ACCOUNT}:teambook`]);
});
it('says which account failed, with what the others gave', async () => {
const { client, engine } = await offlineClient(seeded());
const failPrimary: Partial<OfflineHooks> = {
skipNetwork: () => true,
answer: async (_client, calls) => (calls[0][1].accountId === JMAP_ACCOUNT
? { methodResponses: calls.map(([, , id]) => ['error', { type: 'serverUnavailable' }, id]) }
: engine.answer(calls)),
record: () => {},
noteNetwork: () => {},
};
setOfflineHooks(failPrimary as OfflineHooks);
const contacts = await client.getAllContacts().catch((error: unknown) => error);
const partial = partialAccounts<ContactCard>(contacts);
expect(partial?.failedAccountIds).toEqual([JMAP_ACCOUNT]);
expect(partial?.primaryAccountId).toBe(JMAP_ACCOUNT);
expect(partial?.items.map((c) => c.name?.full)).toEqual(['Dora Team']);
const books = partialAccounts<AddressBook>(await client.getAllAddressBooks().catch((error: unknown) => error));
expect(books?.failedAccountIds).toEqual([JMAP_ACCOUNT]);
expect(books?.items.map((b) => b.originalId)).toEqual(['teambook']);
});
it('searches with its own filter', async () => {
const { client } = await offlineClient(seeded());
expect((await client.searchContacts('müll')).map((c) => c.name?.full)).toEqual(['Carla Müller']);
expect((await client.searchContacts('team')).map((c) => c.originalId)).toEqual(['c1']);
});
it('creates, edits and deletes through its own methods with the stand-in id', async () => {
const { client, engine } = await offlineClient(seeded());
const created = await client.createContact({ name: { full: 'Neue Person' }, emails: { e: { address: 'neu@example.org' } } });
expect(created.id).toBeTruthy();
expect(created.addressBookIds).toEqual({ book1: true });
await client.updateContact(created.id, { name: { full: 'Neue Person B' } });
expect((await client.getContact(created.id))?.name?.full).toBe('Neue Person B');
expect((await client.searchContacts('person b')).map((c) => c.id)).toEqual([created.id]);
await client.deleteContact(created.id);
expect(await client.getContact(created.id)).toBeNull();
expect(await engine.areaCall('contacts.pending', undefined)).toBe(0);
});
it('throws the server-style error for an edit to a card it does not have', async () => {
const { client } = await offlineClient(seeded());
await expect(client.updateContact('nope', { name: { full: 'X' } })).rejects.toThrow();
await expect(client.deleteContact('nope')).rejects.toThrow();
});
it('reads an account the store has as the primary contacts account', async () => {
const { client } = await offlineClient(seeded());
expect(client.getContactsAccountId()).toBe(JMAP_ACCOUNT);
expect((await client.getContacts('book1')).length).toBe(3);
});
});
+88
View File
@@ -0,0 +1,88 @@
// @vitest-environment node
/**
* The real JMAPClient listing files from the store, in each way a server says
* what a node is, plus what it does with a write.
*/
import { afterEach, describe, expect, it } from 'vitest';
import { JMAPClient } from '@/lib/jmap/client';
import { fileNodeKind } from '@/lib/jmap/filenode-kind';
import { setOfflineHooks, type OfflineHooks } from '@/lib/jmap/offline-hooks';
import { DEFAULT_SCOPE } from '../area';
import { AccountEngine } from '../engine/account-engine';
import { filesArea } from '../files';
import { FakeFiles, FILES, FILES_SESSION, SHARED_FILES_ACCOUNT, type FakeFilesOptions } from './helpers/fake-files';
import { openMemoryStore } from './helpers/node-adapter';
afterEach(() => setOfflineHooks(null));
async function offlineClient(server: FakeFiles): Promise<JMAPClient> {
const engine = new AccountEngine({
account: 'a@fake.test',
db: openMemoryStore(),
session: FILES_SESSION,
transport: server,
areas: [filesArea],
scope: DEFAULT_SCOPE,
sleep: async () => {},
});
await engine.requestSync();
engine.setOnline(false);
setOfflineHooks({
skipNetwork: () => true,
answer: (_client, calls) => engine.answer(calls),
record: () => {},
noteNetwork: () => {},
} as Partial<OfflineHooks> as OfflineHooks);
return JMAPClient.fromOfflineSession('https://fake.test', 'a@example.org', {
...FILES_SESSION,
capabilities: { ...FILES_SESSION.capabilities, 'urn:ietf:params:jmap:core': { maxObjectsInGet: 500 } },
});
}
describe('file listing offline', () => {
it.each<[string, FakeFilesOptions]>([
['nodeType (0.16.6 and later, 1.0 as tested)', { style: 'nodeType' }],
['blobId null only (0.16.0-5)', { style: 'blob' }],
['@type and mediaType (1.0.0)', { style: 'draft' }],
])('tells folders from files: %s', async (_label, options) => {
const server = new FakeFiles(options);
const folder = server.addFolder('Docs');
server.addFile('note.txt', folder.id);
const client = await offlineClient(server);
expect(client.supportsFiles()).toBe(true);
const nodes = await client.listAllFileNodes();
const byName = new Map(nodes.map((n) => [n.name, n]));
expect(fileNodeKind(byName.get('Docs') as never)).toBe('directory');
expect(fileNodeKind(byName.get('note.txt') as never)).toBe('file');
expect(byName.get('note.txt')).toMatchObject({ parentId: folder.id, type: 'text/plain', size: 10 });
expect((await client.listFileNodes(folder.id)).map((n) => n.name)).toEqual(['note.txt']);
expect((await client.listFileNodes(null)).map((n) => n.name)).toEqual(['Docs']);
});
it('lists an account larger than one /get, ending its paging cleanly', async () => {
const server = new FakeFiles({ getAll: 'refuse' });
const folder = server.addFolder('Docs');
for (let i = 0; i < 700; i += 1) server.addFile(`f${i}`, folder.id);
const client = await offlineClient(server);
expect(await client.listAllFileNodes()).toHaveLength(701);
});
it('lists shared accounts with namespaced ids', async () => {
const server = new FakeFiles();
server.addFolder('Mine');
const shared = server.addFolder('Theirs', null, undefined, SHARED_FILES_ACCOUNT);
const client = await offlineClient(server);
const nodes = await client.listAllFileNodesAcrossAccounts();
expect(nodes.find((n) => n.name === 'Theirs')).toMatchObject({ id: `${SHARED_FILES_ACCOUNT}:${shared.id}`, isShared: true });
expect(nodes.find((n) => n.name === 'Mine')).toMatchObject({ isShared: false });
});
it('fails a write with an error rather than losing it', async () => {
const server = new FakeFiles();
const client = await offlineClient(server);
await expect(client.createFileDirectory('New', null)).rejects.toThrow();
expect(FILES in FILES_SESSION.capabilities).toBe(true);
expect(server.calls.some(([n]) => n === 'FileNode/set')).toBe(false);
});
});
File diff suppressed because it is too large Load Diff
+892
View File
@@ -0,0 +1,892 @@
// @vitest-environment node
import { describe, expect, it } from 'vitest';
import { DEFAULT_SCOPE, type StoreEvent, type StoreScope } from '../area';
import { AccountEngine } from '../engine/account-engine';
import { TransportError } from '../engine/transport';
import { fileBlob, FILE_CACHE_CAP_BYTES, filesArea } from '../files';
import type { StorageAdapter } from '../storage';
import { JMAP_ACCOUNT } from './helpers/fake-jmap';
import { FakeFiles, FILES_SESSION, SHARED_FILES_ACCOUNT } from './helpers/fake-files';
import { openMemoryStore } from './helpers/node-adapter';
const NOW = Date.UTC(2026, 10, 3);
/* eslint-disable @typescript-eslint/no-explicit-any -- answers are shaped like the server's */
interface Rig {
db: StorageAdapter;
engine: AccountEngine;
clock: { at: number };
events: StoreEvent[];
}
function rigFor(server: FakeFiles, scope: Partial<StoreScope> = {}): Rig {
const db = openMemoryStore();
const clock = { at: NOW };
const events: StoreEvent[] = [];
const engine = new AccountEngine({
account: 'a@fake.test',
db,
session: FILES_SESSION,
transport: server,
areas: [filesArea],
scope: { ...DEFAULT_SCOPE, ...scope },
emit: (event) => events.push(event),
now: () => clock.at,
sleep: async () => {},
});
return { db, engine, clock, events };
}
async function synced(server: FakeFiles, scope: Partial<StoreScope> = {}): Promise<Rig> {
const rig = rigFor(server, scope);
await rig.engine.requestSync();
return rig;
}
async function call(engine: AccountEngine, name: string, args: Record<string, unknown> = {}): Promise<any> {
const response = await engine.answer([[name, { accountId: JMAP_ACCOUNT, ...args }, '0']]);
if (!response) throw new Error('nothing answered');
const [answered, result] = response.methodResponses[0];
if (answered === 'error') throw new Error(result.type);
return result;
}
const ref = (id: string, jmap = JMAP_ACCOUNT) => ({ jmap, id });
const bytesOf = async (kept: unknown): Promise<Uint8Array> => new Uint8Array(await (kept as { blob: Blob }).blob.arrayBuffer());
const keptIn = (db: StorageAdapter, blobId: string, scope: StoreScope = DEFAULT_SCOPE) => fileBlob({ db, scope }, JMAP_ACCOUNT, blobId);
function names(rows: Array<{ name?: unknown }>): unknown[] {
return rows.map((row) => row.name).sort();
}
describe('file metadata sync', () => {
it('stores small accounts from one FileNode/get', async () => {
const server = new FakeFiles();
const folder = server.addFolder('Docs');
server.addFile('a.txt', folder.id);
const { engine } = await synced(server);
expect(server.calls.filter(([n]) => n === 'FileNode/query')).toHaveLength(0);
expect(names((await call(engine, 'FileNode/get', { ids: null })).list)).toEqual(['Docs', 'a.txt']);
});
it.each([
['0.16 cuts the list at 500', { getAll: 'cut' as const }],
['1.0 refuses a list over 500', { getAll: 'refuse' as const }],
])('pages past the /get limit: %s', async (_label, options) => {
const server = new FakeFiles(options);
const folder = server.addFolder('Docs');
for (let i = 0; i < 1234; i += 1) server.addFile(`f${i}.txt`, folder.id);
const { db } = await synced(server);
expect(Number(db.get<{ n: number }>('SELECT COUNT(*) AS n FROM file_node')?.n)).toBe(1235);
for (const [name, args] of server.calls) {
if (name === 'FileNode/get' && Array.isArray(args.ids)) expect(args.ids.length).toBeLessThanOrEqual(500);
}
});
it('finds folders the query leaves out on old servers, by the parents of what it did find', async () => {
const server = new FakeFiles({ queryOmitsFolders: true, style: 'blob' });
for (let i = 0; i < 600; i += 1) server.addFile(`early${i}.txt`);
const top = server.addFolder('Top');
const inner = server.addFolder('Inner', top.id);
server.addFile('deep.txt', inner.id);
const { db } = await synced(server);
const kinds = db.all<{ name: string; kind: string }>("SELECT name, kind FROM file_node WHERE name IN ('Top', 'Inner', 'deep.txt') ORDER BY name");
expect(kinds).toEqual([{ name: 'Inner', kind: 'directory' }, { name: 'Top', kind: 'directory' }, { name: 'deep.txt', kind: 'file' }]);
});
it('continues a stopped full sync from its cursor', async () => {
const server = new FakeFiles({ getAll: 'refuse' });
for (let i = 0; i < 6000; i += 1) server.addFile(`f${i}`);
const rig = rigFor(server);
let gets = 0;
server.beforeRequest = () => {
gets += 1;
// State capture, the refused list, a query page of 5000 and two requests of gets: then the network goes.
if (gets === 6) server.offline = true;
};
await rig.engine.requestSync();
expect(Number(rig.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM file_node')?.n)).toBe(5000);
server.offline = false;
server.beforeRequest = null;
server.calls.length = 0;
await rig.engine.requestSync();
expect(Number(rig.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM file_node')?.n)).toBe(6000);
expect(server.calls.find(([n, a]) => n === 'FileNode/query' && a.accountId === JMAP_ACCOUNT)?.[1].anchor).toBeTruthy();
expect(server.calls.filter(([n, a]) => n === 'FileNode/get' && a.ids === null && a.accountId === JMAP_ACCOUNT)).toHaveLength(0);
});
it('follows changes through FileNode/changes', async () => {
const server = new FakeFiles();
const a = server.addFile('a.txt');
const b = server.addFile('b.txt');
const { engine } = await synced(server);
server.calls.length = 0;
server.renameNode(a.id, 'renamed.txt');
server.removeNode(b.id);
server.addFolder('New');
await engine.requestSync();
expect(server.calls.some(([n]) => n === 'FileNode/changes')).toBe(true);
expect(server.calls.some(([n, args]) => n === 'FileNode/get' && args.ids === null)).toBe(false);
expect(names((await call(engine, 'FileNode/get', { ids: null })).list)).toEqual(['New', 'renamed.txt']);
});
it('starts over when the server can no longer give changes, and keeps a pin on a file still there', async () => {
const server = new FakeFiles();
const gone = server.addFile('gone.txt');
const kept = server.addFile('kept.txt');
const { db, engine } = await synced(server);
await engine.areaCall('files.pin', ref(kept.id));
await engine.requestSync();
server.removeNode(gone.id);
server.account().coll.truncatedBelow = server.account().coll.log.length;
await engine.requestSync();
expect(db.all('SELECT id FROM file_node').map((r) => r.id)).toEqual([kept.id]);
expect(db.get<{ pinned: number }>('SELECT pinned FROM file_node WHERE id = ?', [kept.id])?.pinned).toBe(1);
});
it('keeps accounts apart, even with the same ids', async () => {
const server = new FakeFiles();
server.addFile('own.txt', null, 5, { id: 'same' });
server.addFile('team.txt', null, 5, { id: 'same', blobId: 'team-blob' }, SHARED_FILES_ACCOUNT);
const { engine } = await synced(server);
expect(names((await call(engine, 'FileNode/get', { ids: ['same'] })).list)).toEqual(['own.txt']);
expect(names((await call(engine, 'FileNode/get', { ids: ['same'], accountId: SHARED_FILES_ACCOUNT })).list)).toEqual(['team.txt']);
});
});
describe('file answers', () => {
it('says so instead of an empty list when nothing was read yet', async () => {
const { engine } = rigFor(new FakeFiles());
expect(await engine.answer([['FileNode/get', { accountId: JMAP_ACCOUNT, ids: null }, '0']])).toBeNull();
});
it('answers serverUnavailable until the first full pass is done', async () => {
const server = new FakeFiles({ getAll: 'refuse' });
for (let i = 0; i < 6000; i += 1) server.addFile(`f${i}`);
const rig = rigFor(server);
let requests = 0;
server.beforeRequest = () => {
requests += 1;
if (requests === 6) server.offline = true;
};
await rig.engine.requestSync();
expect(Number(rig.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM file_node')?.n)).toBe(5000);
expect(await rig.engine.answer([['FileNode/get', { accountId: JMAP_ACCOUNT, ids: null }, '0']])).toBeNull();
expect(await rig.engine.answer([['FileNode/query', { accountId: JMAP_ACCOUNT, filter: {} }, '0']])).toBeNull();
server.offline = false;
server.beforeRequest = null;
await rig.engine.requestSync();
expect((await call(rig.engine, 'FileNode/query', { filter: {}, calculateTotal: true })).total).toBe(6000);
});
it('goes on answering from the old copy while a later resync runs', async () => {
const server = new FakeFiles({ getAll: 'refuse' });
for (let i = 0; i < 6000; i += 1) server.addFile(`f${i}`);
const { db, engine } = await synced(server);
db.run("UPDATE sync_state SET state = 'bad state!' WHERE type = 'FileNode'");
let requests = 0;
server.beforeRequest = () => {
requests += 1;
if (requests === 3) server.offline = true;
};
await engine.requestSync();
expect((await call(engine, 'FileNode/query', { filter: {}, calculateTotal: true })).total).toBe(6000);
});
it('does not skip a node when an earlier one is deleted between pages, and starts over if the anchor goes', async () => {
for (const victim of [0, 4999]) {
const server = new FakeFiles({ getAll: 'refuse' });
const files = Array.from({ length: 6000 }, (_, i) => server.addFile(`f${i}`));
const rig = rigFor(server);
let queries = 0;
const original = server.request.bind(server);
server.request = async (calls) => {
if (calls.some(([name, args]) => name === 'FileNode/query' && args.accountId === JMAP_ACCOUNT)) {
queries += 1;
if (queries === 2) server.removeNode(files[victim].id);
}
return original(calls);
};
await rig.engine.requestSync();
const kept = new Set(rig.db.all<{ id: string }>('SELECT id FROM file_node').map((r) => r.id));
expect(kept.size).toBe(5999);
expect(kept.has(files[victim].id)).toBe(false);
}
});
it('gives the last server state and only asked-for properties', async () => {
const server = new FakeFiles();
const file = server.addFile('a.txt');
const { engine } = await synced(server);
const got = await call(engine, 'FileNode/get', { ids: [file.id, 'nope'], properties: ['name', 'parentId'] });
expect(got.state).toBe(server.account().coll.state());
expect(got.list).toEqual([{ id: file.id, name: 'a.txt', parentId: null }]);
expect(got.notFound).toEqual(['nope']);
});
it('answers a query by parent, also at the root, which the real server refuses to take', async () => {
const server = new FakeFiles();
const folder = server.addFolder('Docs');
const inside = server.addFile('in.txt', folder.id);
const root = server.addFile('root.txt');
const { engine } = await synced(server);
expect((await call(engine, 'FileNode/query', { filter: { parentId: folder.id } })).ids).toEqual([inside.id]);
expect((await call(engine, 'FileNode/query', { filter: { parentId: null } })).ids).toEqual([folder.id, root.id]);
});
it('pages and sorts a query', async () => {
const server = new FakeFiles();
server.addFile('b', null, 30);
server.addFile('a', null, 10);
server.addFile('c', null, 20);
const { engine } = await synced(server);
const page = await call(engine, 'FileNode/query', { filter: {}, position: 1, limit: 1, calculateTotal: true });
expect(page).toMatchObject({ position: 1, total: 3, ids: [expect.any(String)] });
const bySize = await call(engine, 'FileNode/query', { filter: {}, sort: [{ property: 'size', isAscending: false }] });
const list = (await call(engine, 'FileNode/get', { ids: bySize.ids })).list;
expect(list.map((n: { name: string }) => n.name)).toEqual(['b', 'c', 'a']);
});
it('refuses what it cannot read, for that call only', async () => {
const server = new FakeFiles();
server.addFile('a');
const { engine } = await synced(server);
const response = await engine.answer([
['FileNode/query', { accountId: JMAP_ACCOUNT, filter: { hasFoo: true } }, '0'],
['FileNode/get', { accountId: JMAP_ACCOUNT, ids: null }, '1'],
]);
expect(response?.methodResponses[0][1].type).toBe('unsupportedFilter');
expect(response?.methodResponses[1][0]).toBe('FileNode/get');
});
it('answers FileNode/set with a clear per-call error, not silence', async () => {
const server = new FakeFiles();
server.addFile('a');
const { engine } = await synced(server);
const response = await engine.answer([
['FileNode/set', { accountId: JMAP_ACCOUNT, create: { n0: { name: 'x' } } }, '0'],
['FileNode/get', { accountId: JMAP_ACCOUNT, ids: null }, '1'],
]);
expect(response?.methodResponses[0]).toEqual(['error', { type: 'serverUnavailable', description: 'FileNode/set needs the server' }, '0']);
expect(response?.methodResponses[1][0]).toBe('FileNode/get');
expect(server.calls.some(([n]) => n === 'FileNode/set')).toBe(false);
});
});
describe('pinned files', () => {
it('fetches a pinned file and gives it back byte for byte, past the chunk size', async () => {
const server = new FakeFiles();
const content = new Uint8Array(3 * 1024 * 1024 + 17).map((_, i) => i % 251);
const file = server.addFile('big.bin', null, content);
const { db, engine } = await synced(server);
expect(server.downloads).toEqual([]);
expect(await engine.areaCall('files.pin', ref(file.id))).toEqual({ kept: false });
await engine.requestSync();
expect(server.downloads).toEqual([{ jmap: JMAP_ACCOUNT, blobId: file.blobId }]);
expect(Number(db.get<{ n: number }>('SELECT COUNT(*) AS n FROM file_blob_chunk')?.n)).toBe(4);
const read = await engine.areaCall('files.read', ref(file.id)) as any;
expect(read.name).toBe('big.bin');
expect(read.type).toBe('text/plain');
expect(Buffer.compare(await bytesOf(read), content)).toBe(0);
expect(Buffer.compare(await bytesOf(keptIn(db, file.blobId as string)), content)).toBe(0);
expect(await engine.areaCall('files.blob', { jmap: JMAP_ACCOUNT, blobId: file.blobId })).toMatchObject({ size: content.length });
// Already kept: not fetched again.
await engine.requestSync();
expect(server.downloads).toHaveLength(1);
expect(await engine.areaCall('files.pin', ref(file.id))).toEqual({ kept: true });
});
it('names a file with a %-encoded name from 0.16 WebDAV the way the client shows it', async () => {
const server = new FakeFiles();
const file = server.addFile('My%20Report.pdf');
const { engine } = await synced(server);
await engine.areaCall('files.pin', ref(file.id));
await engine.requestSync();
expect((await engine.areaCall('files.read', ref(file.id)) as any).name).toBe('My Report.pdf');
expect((await engine.areaCall('files.kept', undefined) as any).files[0].name).toBe('My Report.pdf');
// The stored node stays as the server sent it. The client decodes it once.
expect((await call(engine, 'FileNode/get', { ids: [file.id] })).list[0].name).toBe('My%20Report.pdf');
});
it('gives nothing for a file not kept', async () => {
const server = new FakeFiles();
const file = server.addFile('a.txt');
const { db, engine } = await synced(server);
expect(await engine.areaCall('files.read', ref(file.id))).toBeNull();
expect(keptIn(db, file.blobId as string)).toBeNull();
});
it('refuses to pin a folder or an unknown id', async () => {
const server = new FakeFiles();
const folder = server.addFolder('Docs');
const { engine } = await synced(server);
await expect(engine.areaCall('files.pin', ref(folder.id))).rejects.toThrow('Only files');
await expect(engine.areaCall('files.pin', ref('nope'))).rejects.toThrow();
});
it('downloads one at a time', async () => {
const server = new FakeFiles();
const files = [server.addFile('a'), server.addFile('b'), server.addFile('c')];
const { engine } = await synced(server);
for (const file of files) await engine.areaCall('files.pin', ref(file.id));
await engine.requestSync();
expect(server.downloads).toHaveLength(3);
expect(server.mostAtOnce).toBe(1);
});
it('fetches a pinned file over the one-file limit, and skips an opened one', async () => {
const server = new FakeFiles();
const pinned = server.addFile('pinned.bin', null, 100);
const opened = server.addFile('opened.bin', null, 100);
const { engine } = await synced(server, { maxAttachmentBytes: 50 });
await engine.areaCall('files.pin', ref(pinned.id));
await engine.areaCall('files.noteOpened', ref(opened.id));
await engine.requestSync();
expect(server.downloads.map((d) => d.blobId)).toEqual([pinned.blobId]);
const status = await engine.areaCall('files.kept', undefined) as any;
const row = (name: string) => status.files.find((f: { name: string }) => f.name === name);
expect(row('pinned.bin')).toMatchObject({ pinned: true, kept: true, error: null });
expect(row('opened.bin')).toMatchObject({ pinned: false, kept: false, error: 'tooLarge' });
});
it('does not take pins past the total limit, and says so', async () => {
const server = new FakeFiles();
const a = server.addFile('a', null, 60);
const b = server.addFile('b', null, 60);
const { engine, clock } = await synced(server, { maxTotalBytes: 100 });
await engine.areaCall('files.pin', ref(a.id));
clock.at += 1;
await engine.areaCall('files.pin', ref(b.id));
await engine.requestSync();
expect(server.downloads).toHaveLength(1);
const status = await engine.areaCall('files.kept', undefined) as any;
expect(status.files.filter((f: { kept: boolean }) => f.kept)).toHaveLength(1);
expect(status.files.filter((f: { error: string | null }) => f.error === 'overLimit')).toHaveLength(1);
});
describe('a tightened scope, with no network', () => {
async function keptOffline(rig: Rig) {
rig.engine.setOnline(false);
}
it('drops everything kept when files are forbidden, and the pins stay', async () => {
const server = new FakeFiles();
const file = server.addFile('a', null, 60);
const rig = await synced(server);
await rig.engine.areaCall('files.pin', ref(file.id));
await rig.engine.requestSync();
await keptOffline(rig);
rig.engine.setScope({ ...DEFAULT_SCOPE, attachments: false });
await rig.engine.requestSync();
expect(await rig.engine.areaCall('files.read', ref(file.id))).toBeNull();
expect((await rig.engine.areaCall('files.kept', undefined) as any).files[0]).toMatchObject({ pinned: true, kept: false, error: 'notAllowed' });
});
it('drops an opened file over the new size, not a pinned one', async () => {
const server = new FakeFiles();
const pinned = server.addFile('pinned', null, 60);
const opened = server.addFile('opened', null, 60);
const rig = await synced(server);
await rig.engine.areaCall('files.pin', ref(pinned.id));
await rig.engine.areaCall('files.noteOpened', ref(opened.id));
await rig.engine.requestSync();
await keptOffline(rig);
rig.engine.setScope({ ...DEFAULT_SCOPE, maxAttachmentBytes: 10 });
await rig.engine.requestSync();
expect(await rig.engine.areaCall('files.read', ref(opened.id))).toBeNull();
expect(await rig.engine.areaCall('files.read', ref(pinned.id))).not.toBeNull();
});
it('holds a pin to the organisation\'s cap on one file, not to the user\'s own', async () => {
const server = new FakeFiles();
const pinned = server.addFile('pinned', null, 60);
const rig = await synced(server);
await rig.engine.areaCall('files.pin', ref(pinned.id));
await rig.engine.requestSync();
await keptOffline(rig);
rig.engine.setScope({ ...DEFAULT_SCOPE, maxAttachmentBytes: 10, orgMaxFileBytes: null });
await rig.engine.requestSync();
expect(await rig.engine.areaCall('files.read', ref(pinned.id))).not.toBeNull();
rig.engine.setScope({ ...DEFAULT_SCOPE, maxAttachmentBytes: 10, orgMaxFileBytes: 10 });
await rig.engine.requestSync();
expect(await rig.engine.areaCall('files.read', ref(pinned.id))).toBeNull();
expect((await rig.engine.areaCall('files.kept', undefined) as any).files[0]).toMatchObject({ pinned: true, kept: false, error: 'tooLarge' });
// Not fetched back while the cap holds; fetched back once it's lifted.
server.downloads.length = 0;
rig.engine.setOnline(true);
await rig.engine.requestSync();
expect(server.downloads).toEqual([]);
rig.engine.setScope({ ...DEFAULT_SCOPE, orgMaxFileBytes: null });
await rig.engine.requestSync();
expect(await rig.engine.areaCall('files.read', ref(pinned.id))).not.toBeNull();
});
it('holds even pins to a lower total, newest out first, and doesn\'t fetch them back', async () => {
const server = new FakeFiles();
const older = server.addFile('older', null, 60);
const newer = server.addFile('newer', null, 60);
const rig = await synced(server);
await rig.engine.areaCall('files.pin', ref(older.id));
await rig.engine.requestSync();
rig.clock.at += 1000;
await rig.engine.areaCall('files.pin', ref(newer.id));
await rig.engine.requestSync();
await keptOffline(rig);
rig.engine.setScope({ ...DEFAULT_SCOPE, maxTotalBytes: 100 });
await rig.engine.requestSync();
expect(await rig.engine.areaCall('files.read', ref(older.id))).not.toBeNull();
expect(await rig.engine.areaCall('files.read', ref(newer.id))).toBeNull();
const status = (await rig.engine.areaCall('files.kept', undefined) as any).files;
expect(status.find((f: { id: string }) => f.id === newer.id).error).toBe('overLimit');
server.downloads.length = 0;
rig.engine.setOnline(true);
await rig.engine.requestSync();
expect(server.downloads).toEqual([]);
});
});
it('counts towards the storage page, and takes its part of the total from mail', async () => {
const server = new FakeFiles();
const file = server.addFile('a', null, 60);
const rig = await synced(server);
await rig.engine.areaCall('files.pin', ref(file.id));
await rig.engine.requestSync();
const usage = await rig.engine.areaCall('store.usage', undefined) as { parts: Array<{ part: string; bytes: number; items: number }> };
expect(usage.parts).toContainEqual({ part: 'files', bytes: 60, items: 1 });
expect(filesArea.keptBytes?.({ db: rig.db })).toBe(60);
});
it('keeps nothing when the scope forbids it, and drops what was kept', async () => {
const server = new FakeFiles();
const file = server.addFile('a');
const rig = await synced(server);
await rig.engine.areaCall('files.pin', ref(file.id));
await rig.engine.requestSync();
expect(await rig.engine.areaCall('files.read', ref(file.id))).not.toBeNull();
rig.engine.setScope({ ...DEFAULT_SCOPE, attachments: false });
await rig.engine.requestSync();
expect(await rig.engine.areaCall('files.read', ref(file.id))).toBeNull();
expect(Number(rig.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM file_blob_chunk')?.n)).toBe(0);
expect((await rig.engine.areaCall('files.kept', undefined) as any).files[0]).toMatchObject({ pinned: true, kept: false, error: 'notAllowed' });
rig.engine.setScope(DEFAULT_SCOPE);
await rig.engine.requestSync();
expect(await rig.engine.areaCall('files.read', ref(file.id))).not.toBeNull();
});
it('goes by the real size when the metadata has it wrong', async () => {
const server = new FakeFiles();
const opened = server.addFile('opened', null, new Uint8Array(100), { size: 1 });
const { engine } = await synced(server, { maxAttachmentBytes: 50 });
await engine.areaCall('files.noteOpened', ref(opened.id));
await engine.requestSync();
expect(server.downloads).toHaveLength(1);
expect(await engine.areaCall('files.read', ref(opened.id))).toBeNull();
expect((await engine.areaCall('files.kept', undefined) as any).files[0].error).toBe('tooLarge');
});
it('stops a download at the limit when the size is missing or wrong', async () => {
const server = new FakeFiles();
server.partBytes = 10;
const noSize = server.addFile('noSize', null, new Uint8Array(1000), { size: null });
const wrong = server.addFile('wrong', null, new Uint8Array(1000), { size: 1 });
const { db, engine } = await synced(server, { maxAttachmentBytes: 50 });
await engine.areaCall('files.noteOpened', ref(noSize.id));
await engine.areaCall('files.noteOpened', ref(wrong.id));
await engine.requestSync();
expect(server.delivered.get(noSize.blobId as string)).toBeLessThanOrEqual(60);
expect(server.delivered.get(wrong.blobId as string)).toBeLessThanOrEqual(60);
expect(Number(db.get<{ n: number }>('SELECT COUNT(*) AS n FROM file_blob_chunk')?.n)).toBe(0);
expect(Number(db.get<{ n: number }>('SELECT COUNT(*) AS n FROM file_blob')?.n)).toBe(0);
// Noted, so the next pass doesn't start it again.
const count = server.downloads.length;
await engine.requestSync();
expect(server.downloads).toHaveLength(count);
});
it('stops a pinned download at the total limit too', async () => {
const server = new FakeFiles();
server.partBytes = 10;
const big = server.addFile('big', null, new Uint8Array(1000), { size: null });
const { engine } = await synced(server, { maxTotalBytes: 100 });
await engine.areaCall('files.pin', ref(big.id));
await engine.requestSync();
expect(server.delivered.get(big.blobId as string)).toBeLessThanOrEqual(110);
expect((await engine.areaCall('files.kept', undefined) as any).files[0]).toMatchObject({ kept: false, error: 'overLimit' });
});
it('stops a pinned download at the organisation limit, whatever the size says', async () => {
const server = new FakeFiles();
server.partBytes = 10;
const big = server.addFile('big', null, new Uint8Array(1000), { size: null });
const { engine } = await synced(server, { orgMaxFileBytes: 50 });
await engine.areaCall('files.pin', ref(big.id));
await engine.requestSync();
expect(server.delivered.get(big.blobId as string)).toBeLessThanOrEqual(60);
expect((await engine.areaCall('files.kept', undefined) as any).files[0]).toMatchObject({ kept: false, error: 'tooLarge' });
});
it('writes a download in pieces and gives nothing out until it is complete', async () => {
const server = new FakeFiles();
const file = server.addFile('big', null, new Uint8Array(3 * 1024 * 1024).fill(7));
const { db, engine } = await synced(server);
await engine.areaCall('files.pin', ref(file.id));
const seen: number[] = [];
let readDuring: unknown = 'not read';
server.onPart = () => {
seen.push(Number(db.get<{ n: number }>('SELECT COUNT(*) AS n FROM file_blob_chunk')?.n));
readDuring = keptIn(db, file.blobId as string);
};
await engine.requestSync();
expect(Math.max(...seen)).toBeGreaterThanOrEqual(2);
expect(readDuring).toBeNull();
expect((await bytesOf(keptIn(db, file.blobId as string))).length).toBe(3 * 1024 * 1024);
});
it('leaves no pieces behind after a broken download, and gives none out', async () => {
const server = new FakeFiles();
const file = server.addFile('big', null, new Uint8Array(3 * 1024 * 1024).fill(7));
// Twice, since the pin itself starts a pass and the sync below asks for another.
server.failMidBody.set(file.blobId as string, { after: 2 * 1024 * 1024, times: 2 });
const { db, engine, clock } = await synced(server);
await engine.areaCall('files.pin', ref(file.id));
await engine.requestSync();
expect(server.failMidBody.get(file.blobId as string)?.times).toBe(0);
expect(await engine.areaCall('files.read', ref(file.id))).toBeNull();
expect(Number(db.get<{ n: number }>('SELECT COUNT(*) AS n FROM file_blob_chunk')?.n)).toBe(0);
clock.at += 60 * 60 * 1000;
await engine.requestSync();
expect((await bytesOf(await engine.areaCall('files.read', ref(file.id)))).length).toBe(3 * 1024 * 1024);
});
it('clears the pieces a closed app left behind', async () => {
const server = new FakeFiles();
const file = server.addFile('a');
const { db, engine } = await synced(server);
db.run('INSERT INTO file_blob (jmap, blob_id, type, size, kept_at, complete) VALUES (?, ?, ?, 0, 0, 0)', [JMAP_ACCOUNT, 'unfinished', 'x']);
db.run('INSERT INTO file_blob_chunk (jmap, blob_id, seq, data) VALUES (?, ?, 0, ?)', [JMAP_ACCOUNT, 'unfinished', new Uint8Array(5)]);
expect(keptIn(db, 'unfinished')).toBeNull();
await engine.areaCall('files.pin', ref(file.id));
await engine.requestSync();
expect(Number(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM file_blob_chunk WHERE blob_id = 'unfinished'")?.n)).toBe(0);
expect(Number(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM file_blob WHERE blob_id = 'unfinished'")?.n)).toBe(0);
});
it('marks a file that keeps breaking and gets on with the others', async () => {
const server = new FakeFiles();
const bad = server.addFile('bad', null, new Uint8Array(100));
const good = server.addFile('good', null, 10);
server.partBytes = 10;
server.failMidBody.set(bad.blobId as string, { after: 50, times: 3 });
const rig = await synced(server);
await rig.engine.areaCall('files.pin', ref(bad.id));
await rig.engine.areaCall('files.pin', ref(good.id));
await rig.engine.requestSync();
expect(await rig.engine.areaCall('files.read', ref(good.id))).not.toBeNull();
const badRow = async () => (await rig.engine.areaCall('files.kept', undefined) as any).files.find((f: { name: string }) => f.name === 'bad');
expect(await badRow()).toMatchObject({ kept: false, error: 'download' });
// Failed files go last, and the second failure starts a wait.
await rig.engine.requestSync();
const tries = server.downloads.filter((d) => d.blobId === bad.blobId).length;
await rig.engine.requestSync();
expect(server.downloads.filter((d) => d.blobId === bad.blobId)).toHaveLength(tries);
rig.clock.at += 60 * 60 * 1000;
await rig.engine.requestSync();
rig.clock.at += 2 * 60 * 60 * 1000;
await rig.engine.requestSync();
expect(await rig.engine.areaCall('files.read', ref(bad.id))).not.toBeNull();
expect(await badRow()).toMatchObject({ kept: true, error: null });
});
it('keeps the old copy of a pinned file until the new one is stored', async () => {
const server = new FakeFiles();
const file = server.addFile('a.txt', null, new Uint8Array([1, 2, 3]));
const { db, engine } = await synced(server);
await engine.areaCall('files.pin', ref(file.id));
await engine.requestSync();
const old = file.blobId as string;
const fresh = server.replaceContent(file.id, new Uint8Array([9, 9]));
server.failMidBody.set(fresh, { after: 0, times: 1 });
await engine.requestSync();
// The new one didn't arrive: what's asked for by its new id is the last copy, marked as old.
expect([...await bytesOf(keptIn(db, old))]).toEqual([1, 2, 3]);
const stale = await engine.areaCall('files.read', ref(file.id)) as any;
expect([...await bytesOf(stale)]).toEqual([1, 2, 3]);
expect(stale.stale).toBe(true);
expect(keptIn(db, fresh)).toMatchObject({ stale: true });
await engine.requestSync();
expect(keptIn(db, old)).toBeNull();
const current = await engine.areaCall('files.read', ref(file.id)) as any;
expect([...await bytesOf(current)]).toEqual([9, 9]);
expect(current.stale).toBe(false);
});
it('drops pins newest first when a policy lowers the limit, as soon as it changes', async () => {
const server = new FakeFiles();
const a = server.addFile('a', null, 60);
const b = server.addFile('b', null, 60);
const c = server.addFile('c', null, 30);
const rig = await synced(server);
for (const file of [a, b, c]) {
rig.clock.at += 1000;
await rig.engine.areaCall('files.pin', ref(file.id));
await rig.engine.requestSync();
}
rig.engine.setScope({ ...DEFAULT_SCOPE, maxTotalBytes: 100 });
const status = await rig.engine.areaCall('files.kept', undefined) as any;
const row = (name: string) => status.files.find((f: { name: string }) => f.name === name);
expect(row('a')).toMatchObject({ kept: true, pinned: true });
expect(row('b')).toMatchObject({ kept: false, pinned: true, error: 'overLimit' });
expect(row('c')).toMatchObject({ kept: true, pinned: true });
expect(status.bytes).toBe(90);
const count = server.downloads.length;
await rig.engine.requestSync();
expect(server.downloads).toHaveLength(count);
rig.engine.setScope(DEFAULT_SCOPE);
await rig.engine.requestSync();
expect((await rig.engine.areaCall('files.kept', undefined) as any).bytes).toBe(150);
});
it('applies a tighter policy at start too, before any sync', async () => {
const server = new FakeFiles();
const file = server.addFile('a');
const rig = await synced(server);
await rig.engine.areaCall('files.pin', ref(file.id));
await rig.engine.requestSync();
await rig.engine.close();
server.offline = true;
new AccountEngine({ account: 'a@fake.test', db: rig.db, session: FILES_SESSION, transport: server, areas: [filesArea], scope: { ...DEFAULT_SCOPE, attachments: false }, sleep: async () => {} });
expect(Number(rig.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM file_blob_chunk')?.n)).toBe(0);
});
it('refuses kept contents with attachments off, and drops them when the scope changes', async () => {
const server = new FakeFiles();
const file = server.addFile('a');
const rig = await synced(server);
await rig.engine.areaCall('files.pin', ref(file.id));
await rig.engine.requestSync();
const off = { ...DEFAULT_SCOPE, attachments: false };
expect(keptIn(rig.db, file.blobId as string, off)).toBeNull();
expect(keptIn(rig.db, file.blobId as string)).not.toBeNull();
rig.engine.setScope(off);
expect(await rig.engine.areaCall('files.read', ref(file.id))).toBeNull();
expect(Number(rig.db.get<{ n: number }>('SELECT COUNT(*) AS n FROM file_blob_chunk')?.n)).toBe(0);
expect((await rig.engine.areaCall('files.kept', undefined) as any).files[0]).toMatchObject({ kept: false, error: 'notAllowed' });
});
it('lets a close end a download at once', async () => {
const server = new FakeFiles();
const file = server.addFile('a');
const { engine } = await synced(server);
await engine.areaCall('files.pin', ref(file.id));
server.hangDownloads = true;
const pass = engine.requestSync();
await new Promise((resolve) => setTimeout(resolve, 5));
expect(server.downloading).toBe(1);
const closed = await Promise.race([engine.close().then(() => 'closed'), new Promise((resolve) => setTimeout(() => resolve('still waiting'), 200))]);
expect(closed).toBe('closed');
await pass;
});
it('does not store a file unpinned while it was downloading', async () => {
const server = new FakeFiles();
const file = server.addFile('a');
const { engine } = await synced(server);
await engine.areaCall('files.pin', ref(file.id));
const original = server.downloadStream.bind(server);
server.downloadStream = async (jmap, blobId, type, name, sink) => {
await engine.areaCall('files.unpin', ref(file.id));
return original(jmap, blobId, type, name, sink);
};
await engine.requestSync();
expect(server.downloads).toHaveLength(1);
expect(await engine.areaCall('files.read', ref(file.id))).toBeNull();
});
it('lets a never-opened file go on unpin, and keeps an opened one as recent', async () => {
const server = new FakeFiles();
const never = server.addFile('never');
const opened = server.addFile('opened');
const { engine } = await synced(server);
await engine.areaCall('files.pin', ref(never.id));
await engine.areaCall('files.pin', ref(opened.id));
await engine.areaCall('files.noteOpened', ref(opened.id));
await engine.requestSync();
await engine.areaCall('files.unpin', ref(never.id));
await engine.areaCall('files.unpin', ref(opened.id));
expect(await engine.areaCall('files.read', ref(never.id))).toBeNull();
expect(await engine.areaCall('files.read', ref(opened.id))).not.toBeNull();
const status = await engine.areaCall('files.kept', undefined) as any;
expect(status.files.map((f: { name: string; pinned: boolean }) => [f.name, f.pinned])).toEqual([['opened', false]]);
});
it('drops the old content and fetches the new one when a pinned file changes', async () => {
const server = new FakeFiles();
const file = server.addFile('a.txt', null, new Uint8Array([1, 2, 3]));
const { db, engine } = await synced(server);
await engine.areaCall('files.pin', ref(file.id));
await engine.requestSync();
const old = file.blobId as string;
const fresh = server.replaceContent(file.id, new Uint8Array([9, 9]));
await engine.requestSync();
expect(keptIn(db, old)).toBeNull();
expect([...await bytesOf(keptIn(db, fresh))]).toEqual([9, 9]);
expect([...await bytesOf(await engine.areaCall('files.read', ref(file.id)))]).toEqual([9, 9]);
});
it('drops the content when the file is removed on the server, unless another node shares it', async () => {
const server = new FakeFiles();
const a = server.addFile('a', null, 5, { blobId: 'shared-blob' });
const b = server.addFile('b', null, 5, { blobId: 'shared-blob' });
const { db, engine } = await synced(server);
await engine.areaCall('files.pin', ref(a.id));
await engine.areaCall('files.pin', ref(b.id));
await engine.requestSync();
expect(server.downloads).toHaveLength(1);
server.removeNode(a.id);
await engine.requestSync();
expect(keptIn(db, 'shared-blob')).not.toBeNull();
server.removeNode(b.id);
await engine.requestSync();
expect(keptIn(db, 'shared-blob')).toBeNull();
expect(Number(db.get<{ n: number }>('SELECT COUNT(*) AS n FROM file_blob_chunk')?.n)).toBe(0);
});
it('carries on past one bad file, and hands a network failure to the engine', async () => {
const server = new FakeFiles();
const bad = server.addFile('bad');
const good = server.addFile('good');
server.blobs.delete(bad.blobId as string);
const { engine, clock } = await synced(server);
await engine.areaCall('files.pin', ref(bad.id));
clock.at += 1;
await engine.areaCall('files.pin', ref(good.id));
await engine.requestSync();
expect(await engine.areaCall('files.read', ref(good.id))).not.toBeNull();
const status = await engine.areaCall('files.kept', undefined) as any;
expect(status.files.find((f: { name: string }) => f.name === 'bad').error).toBe('download');
});
it('hands a network failure to the engine, which ends the pass, and goes on next time', async () => {
const server = new FakeFiles();
const file = server.addFile('a');
const rig = await synced(server);
await rig.engine.areaCall('files.pin', ref(file.id));
const original = server.downloadStream.bind(server);
server.downloadStream = async () => {
throw new TransportError('network', 'Failed to fetch');
};
const syncedBefore = rig.events.filter((e) => e.type === 'synced').length;
await rig.engine.requestSync();
expect(rig.events.some((e) => e.type === 'error')).toBe(false);
expect(rig.events.filter((e) => e.type === 'synced').length).toBe(syncedBefore);
expect((await rig.engine.areaCall('files.kept', undefined) as any).files[0]).toMatchObject({ kept: false, error: null });
server.downloadStream = original;
await rig.engine.requestSync();
expect(await rig.engine.areaCall('files.read', ref(file.id))).not.toBeNull();
});
});
describe('recently opened files', () => {
it('keeps an opened file without a pin', async () => {
const server = new FakeFiles();
const file = server.addFile('a');
const { engine } = await synced(server);
expect(await engine.areaCall('files.noteOpened', ref(file.id))).toEqual({ kept: false });
await engine.requestSync();
expect(await engine.areaCall('files.read', ref(file.id))).not.toBeNull();
expect(await engine.areaCall('files.noteOpened', ref(file.id))).toEqual({ kept: true });
});
it('pushes out the oldest unpinned first, and never a pinned one', async () => {
const server = new FakeFiles();
const files = Object.fromEntries(['old', 'pinnedOldest', 'mid', 'new', 'newest'].map((name) => [name, server.addFile(name, null, 10)]));
const rig = await synced(server, { maxTotalBytes: 30 });
const open = async (name: string) => {
rig.clock.at += 1000;
await rig.engine.areaCall('files.noteOpened', ref(files[name].id));
await rig.engine.requestSync();
};
await open('pinnedOldest');
await rig.engine.areaCall('files.pin', ref(files.pinnedOldest.id));
await open('old');
await open('mid');
const kept = async () => (await rig.engine.areaCall('files.kept', undefined) as any).files
.filter((f: { kept: boolean }) => f.kept).map((f: { name: string }) => f.name).sort();
expect(await kept()).toEqual(['mid', 'old', 'pinnedOldest']);
await open('new');
expect(await kept()).toEqual(['mid', 'new', 'pinnedOldest']);
await open('mid');
await open('newest');
expect(await kept()).toEqual(['mid', 'newest', 'pinnedOldest']);
expect((await rig.engine.areaCall('files.kept', undefined) as any).bytes).toBe(30);
// A pinned file was never pushed out and had to come back.
expect(server.downloads.filter((d) => d.blobId === files.pinnedOldest.blobId)).toHaveLength(1);
// Pushed out means forgotten: the next pass doesn't fetch it straight back.
expect(server.downloads.filter((d) => d.blobId === files.old.blobId)).toHaveLength(1);
const count = server.downloads.length;
await rig.engine.requestSync();
expect(server.downloads).toHaveLength(count);
expect((await rig.engine.areaCall('files.kept', undefined) as any).files.map((f: { name: string }) => f.name)).not.toContain('old');
});
it('pushes out recent files for a pin, and keeps a pin past the cap', async () => {
const server = new FakeFiles();
const recent = server.addFile('recent', null, 20);
const pin = server.addFile('pin', null, 20);
const rig = await synced(server, { maxTotalBytes: 25 });
await rig.engine.areaCall('files.noteOpened', ref(recent.id));
await rig.engine.requestSync();
rig.clock.at += 1000;
await rig.engine.areaCall('files.pin', ref(pin.id));
await rig.engine.requestSync();
expect(await rig.engine.areaCall('files.read', ref(recent.id))).toBeNull();
expect(await rig.engine.areaCall('files.read', ref(pin.id))).not.toBeNull();
});
it('keeps recently opened files under 500 MB whatever the total limit is', async () => {
const server = new FakeFiles();
const file = server.addFile('new', null, 10);
const rig = await synced(server, { maxTotalBytes: 2 * FILE_CACHE_CAP_BYTES });
// As if an older file already took all of it: only the row counts here.
rig.db.run('INSERT INTO file_blob (jmap, blob_id, type, size, kept_at, complete) VALUES (?, ?, ?, ?, ?, 1)', [JMAP_ACCOUNT, 'huge', 'x', FILE_CACHE_CAP_BYTES, NOW - 1000]);
rig.db.run("INSERT INTO file_node (jmap, id, name, kind, blob_id, size, json, opened_at) VALUES (?, 'hugenode', 'huge', 'file', 'huge', ?, '{}', ?)", [JMAP_ACCOUNT, FILE_CACHE_CAP_BYTES, NOW - 1000]);
await rig.engine.areaCall('files.noteOpened', ref(file.id));
await rig.engine.requestSync();
expect(keptIn(rig.db, 'huge')).toBeNull();
expect(await rig.engine.areaCall('files.read', ref(file.id))).not.toBeNull();
expect((await rig.engine.areaCall('files.kept', undefined) as any).capBytes).toBe(FILE_CACHE_CAP_BYTES);
});
it('drops an old opened file that does not fit rather than a newer one', async () => {
const server = new FakeFiles();
const a = server.addFile('a', null, 20);
const b = server.addFile('b', null, 20);
const rig = await synced(server, { maxTotalBytes: 25 });
await rig.engine.areaCall('files.noteOpened', ref(a.id));
rig.clock.at += 1000;
await rig.engine.areaCall('files.noteOpened', ref(b.id));
await rig.engine.requestSync();
expect(await rig.engine.areaCall('files.read', ref(b.id))).not.toBeNull();
expect(await rig.engine.areaCall('files.read', ref(a.id))).toBeNull();
// It can see that from the sizes, so it doesn't even download it.
expect(server.downloads.map((d) => d.blobId)).toEqual([b.blobId]);
const count = server.downloads.length;
await rig.engine.requestSync();
expect(server.downloads).toHaveLength(count);
});
});
/* eslint-enable @typescript-eslint/no-explicit-any */
@@ -0,0 +1,268 @@
/**
* FakeJmap with contacts, as measured on both Stalwarts (2026-11-03): uid is a
* query filter, a second card with a uid is refused with invalidProperties,
* unknown ids give per-record notFound, AddressBook state doesn't move when
* cards change, `ContactCard/get` with ids null stops at 500.
*/
import { TransportError, type JmapResponse, type MethodCall, type StoreSession } from '../../engine/transport';
import { Collection, FAKE_SESSION, FakeJmap, JMAP_ACCOUNT } from './fake-jmap';
export const CONTACTS = 'urn:ietf:params:jmap:contacts';
export const SHARED_ACCOUNT = 'shared1';
export const MAIL_ONLY_ACCOUNT = 'mailonly';
export const CONTACTS_SESSION: StoreSession = {
...FAKE_SESSION,
primaryAccounts: { ...FAKE_SESSION.primaryAccounts, [CONTACTS]: JMAP_ACCOUNT },
accounts: {
[JMAP_ACCOUNT]: { name: 'a@example.org', isPersonal: true, accountCapabilities: { [CONTACTS]: {} } },
[SHARED_ACCOUNT]: { name: 'team@example.org', isPersonal: false, accountCapabilities: { [CONTACTS]: {} } },
[MAIL_ONLY_ACCOUNT]: { name: 'list@example.org', isPersonal: false, accountCapabilities: { 'urn:ietf:params:jmap:mail': {} } },
},
capabilities: { [CONTACTS]: {} },
};
type Obj = Record<string, unknown> & { id: string };
/** The /changes rules of the base fake, for another collection. */
export function changesFor(coll: Collection, args: Record<string, unknown>): Record<string, unknown> {
const since = String(args.sinceState);
if (!/^s\d+$/.test(since)) throw Object.assign(new Error('bad state'), { jmapError: 'invalidArguments' });
const from = Number(since.slice(1));
if (from < coll.truncatedBelow || from > coll.log.length) {
throw Object.assign(new Error('log truncated'), { jmapError: 'cannotCalculateChanges' });
}
const maxChanges = Math.min(Number(args.maxChanges ?? 5000) || 5000, 5000);
return { accountId: args.accountId, oldState: since, ...coll.changes(since, maxChanges) };
}
function storedMedia(card: Obj): void {
const media = card.media as Record<string, Record<string, unknown>> | undefined;
for (const [key, entry] of Object.entries(media ?? {})) {
if (typeof entry.uri !== 'string' || !entry.uri.startsWith('data:')) continue;
const { uri, ...rest } = entry;
(media as Record<string, unknown>)[key] = { ...rest, blobId: `stored-${uri.length}-${key}` };
}
}
function applyServerPatch(card: Obj, patch: Record<string, unknown>): void {
for (const [path, value] of Object.entries(patch)) {
const steps = path.split('/');
let node = card as Record<string, unknown>;
for (const step of steps.slice(0, -1)) node = node[step] as Record<string, unknown>;
const last = steps[steps.length - 1];
if (value === null) delete node[last];
else node[last] = value;
}
}
class ContactAccount {
readonly books = new Map<string, Obj>();
readonly cards = new Map<string, Obj>();
readonly coll = { AddressBook: new Collection(), ContactCard: new Collection() };
}
export class FakeContacts extends FakeJmap {
private readonly contactAccounts = new Map<string, ContactAccount>();
private nextCard = 1;
/** Set to apply the next ContactCard/set and then lose its response. */
loseNextSet = false;
/** Card ids (or creation ids) whose next set is refused with this error. */
readonly refuse = new Map<string, { type: string; description?: string; properties?: string[] }>();
/** A server that lets two cards have one uid, as nothing in the RFC forbids it. */
allowDuplicateUids = false;
/** Stalwart 1.0 stores a data: URI in `media` as a blob: `uri` goes, `blobId` comes (measured). */
mediaAsBlobs = false;
/** Set to apply the next ContactCard/set, answer it, and then lose the network. */
offlineAfterSet = false;
/** Set to lose the network before the next ContactCard/set reaches the server. */
offlineBeforeSet = false;
/** The most ids ContactCard/get and calls a request take, as a session can say. */
maxObjectsInGet = 500;
/** Answers a ContactCard/set call as a whole, instead of handling it. */
failSet: ((args: Record<string, unknown>) => { jmapError: string } | { http: number } | null) | null = null;
constructor(books: string[] = ['book1']) {
super();
for (const id of books) this.addBook({ id, name: id, isDefault: id === books[0] });
}
account(id: string = JMAP_ACCOUNT): ContactAccount {
let found = this.contactAccounts.get(id);
if (!found) {
found = new ContactAccount();
this.contactAccounts.set(id, found);
}
return found;
}
addBook(book: Obj, accountId: string = JMAP_ACCOUNT): void {
const account = this.account(accountId);
account.books.set(book.id, book);
account.coll.AddressBook.push('created', book.id);
}
addCard(partial: Partial<Obj> = {}, accountId: string = JMAP_ACCOUNT): Obj {
const account = this.account(accountId);
const n = this.nextCard++;
const card: Obj = {
'@type': 'Card',
version: '1.0',
id: `c${n}`,
uid: `urn:uuid:card-${n}`,
addressBookIds: { [[...account.books.keys()][0] ?? 'book1']: true },
kind: 'individual',
name: { full: `Person ${n}` },
...partial,
};
account.cards.set(card.id, card);
account.coll.ContactCard.push('created', card.id);
return card;
}
editCard(id: string, patch: Record<string, unknown>, accountId: string = JMAP_ACCOUNT): void {
const account = this.account(accountId);
applyServerPatch(account.cards.get(id) as Obj, patch);
account.coll.ContactCard.push('updated', id);
}
removeCard(id: string, accountId: string = JMAP_ACCOUNT): void {
const account = this.account(accountId);
account.cards.delete(id);
account.coll.ContactCard.push('destroyed', id);
}
private contactSet(account: ContactAccount, args: Record<string, unknown>): Record<string, unknown> {
const result: Record<string, unknown> = { accountId: args.accountId, oldState: account.coll.ContactCard.state() };
const created: Record<string, unknown> = {};
const notCreated: Record<string, unknown> = {};
const updated: Record<string, unknown> = {};
const notUpdated: Record<string, unknown> = {};
const destroyed: string[] = [];
const notDestroyed: Record<string, unknown> = {};
for (const [cid, raw] of Object.entries((args.create ?? {}) as Record<string, Record<string, unknown>>)) {
const refused = this.refuse.get(cid) ?? this.refuse.get(String(raw.uid));
const clash = [...account.cards.values()].find((card) => card.uid === raw.uid);
if (refused) {
notCreated[cid] = refused;
} else if (clash && !this.allowDuplicateUids) {
notCreated[cid] = { type: 'invalidProperties', description: `Contact with UID ${String(raw.uid)} already exists with id ${clash.id}.`, properties: ['uid'] };
} else {
const id = `c${this.nextCard++}`;
const stored: Obj = { '@type': 'Card', version: '1.0', ...structuredClone(raw), id };
if (this.mediaAsBlobs) storedMedia(stored);
account.cards.set(id, stored);
account.coll.ContactCard.push('created', id);
created[cid] = { id };
}
}
for (const [id, patch] of Object.entries((args.update ?? {}) as Record<string, Record<string, unknown>>)) {
const card = account.cards.get(id);
const refused = this.refuse.get(id);
if (refused) notUpdated[id] = refused;
else if (!card) notUpdated[id] = { type: 'notFound' };
else {
applyServerPatch(card, structuredClone(patch));
if (this.mediaAsBlobs) storedMedia(card);
account.coll.ContactCard.push('updated', id);
updated[id] = null;
}
}
for (const id of (args.destroy ?? []) as string[]) {
const refused = this.refuse.get(id);
if (refused) notDestroyed[id] = refused;
else if (!account.cards.has(id)) notDestroyed[id] = { type: 'notFound' };
else {
account.cards.delete(id);
account.coll.ContactCard.push('destroyed', id);
destroyed.push(id);
}
}
for (const [key, value] of Object.entries({ created, notCreated, updated, notUpdated, notDestroyed })) {
if (Object.keys(value).length > 0) result[key] = value;
}
if (destroyed.length > 0) result.destroyed = destroyed;
result.newState = account.coll.ContactCard.state();
return result;
}
protected override handle(name: string, args: Record<string, unknown>): Record<string, unknown> {
const account = this.account(String(args.accountId));
switch (name) {
case 'AddressBook/get': {
const ids = args.ids as string[] | null | undefined;
const all = [...account.books.values()];
return {
accountId: args.accountId,
state: account.coll.AddressBook.state(),
list: ids ? all.filter((b) => ids.includes(b.id)) : all,
notFound: ids ? ids.filter((id) => !account.books.has(id)) : [],
};
}
case 'AddressBook/changes':
return changesFor(account.coll.AddressBook, args);
case 'ContactCard/changes':
return changesFor(account.coll.ContactCard, args);
case 'ContactCard/query': {
const filter = (args.filter ?? {}) as { uid?: string; inAddressBook?: string };
let list = [...account.cards.values()];
if (filter.uid) list = list.filter((c) => c.uid === filter.uid);
if (filter.inAddressBook) list = list.filter((c) => (c.addressBookIds as Record<string, boolean>)[filter.inAddressBook as string]);
let position = Number(args.position ?? 0);
if (args.anchor !== undefined) {
const at = list.findIndex((c) => c.id === args.anchor);
if (at < 0) throw Object.assign(new Error('anchorNotFound'), { jmapError: 'anchorNotFound' });
position = at + Number(args.anchorOffset ?? 0);
}
const limit = Number(args.limit ?? 5000) || 5000;
return {
accountId: args.accountId,
queryState: account.coll.ContactCard.state(),
canCalculateChanges: true,
position,
ids: list.slice(position, position + Math.min(limit, 5000)).map((c) => c.id),
...(args.calculateTotal ? { total: list.length } : {}),
};
}
case 'ContactCard/get': {
const ids = args.ids as string[] | null | undefined;
if (ids && ids.length > this.maxObjectsInGet) throw Object.assign(new Error('requestTooLarge'), { jmapError: 'requestTooLarge' });
const chosen = ids ?? [...account.cards.keys()].slice(0, this.maxObjectsInGet);
return {
accountId: args.accountId,
state: account.coll.ContactCard.state(),
list: chosen.filter((id) => account.cards.has(id)).map((id) => structuredClone(account.cards.get(id))),
notFound: chosen.filter((id) => !account.cards.has(id)),
};
}
case 'ContactCard/set': {
const failure = this.failSet?.(args);
if (failure && 'http' in failure) throw new TransportError('http', `JMAP request failed: ${failure.http}`, failure.http, 'urn:ietf:params:jmap:error:limit');
if (failure) throw Object.assign(new Error(failure.jmapError), failure);
return this.contactSet(account, args);
}
default:
return super.handle(name, args);
}
}
override async request(calls: MethodCall[], using?: string[]): Promise<JmapResponse> {
// The real server fails the whole request for a capability it doesn't list.
void using;
if (this.offlineBeforeSet && calls.some(([name]) => name === 'ContactCard/set')) {
this.offlineBeforeSet = false;
throw new TransportError('network', 'Failed to fetch');
}
const response = await super.request(calls);
if (this.offlineAfterSet && calls.some(([name]) => name === 'ContactCard/set')) {
this.offlineAfterSet = false;
this.offline = true;
}
if (this.loseNextSet && calls.some(([name]) => name === 'ContactCard/set')) {
this.loseNextSet = false;
throw new TransportError('network', 'Connection dropped after the server took the request');
}
return response;
}
}
+230
View File
@@ -0,0 +1,230 @@
/**
* FakeJmap with files, as measured on both Stalwarts (2026-11-03) and as the
* client's comments record: `filter: {parentId: null}` is an HTTP 400 that
* fails the whole request, a folder has null blobId, type and size. What differs
* between versions is set per server:
* - `style` is how a node says what it is: `nodeType` (0.16.6 and later, 1.0 as
* tested), `blob` (0.16.0-5: nothing but blobId null), `draft` (1.0.0: `@type`
* and `mediaType`).
* - `getAll`: ids null past 500 is silently cut on 0.16 and refused on 1.0.
* - `queryOmitsFolders`: FileNode/query before 0.16.6.
*/
import { TransportError, type StoreSession } from '../../engine/transport';
import { changesFor } from './fake-contacts';
import { Collection, FAKE_SESSION, FakeJmap, JMAP_ACCOUNT } from './fake-jmap';
export const FILES = 'urn:ietf:params:jmap:filenode';
export const SHARED_FILES_ACCOUNT = 'shared1';
export const FILES_SESSION: StoreSession = {
...FAKE_SESSION,
primaryAccounts: { ...FAKE_SESSION.primaryAccounts, [FILES]: JMAP_ACCOUNT },
accounts: {
[JMAP_ACCOUNT]: { name: 'a@example.org', isPersonal: true, accountCapabilities: { [FILES]: {} } },
[SHARED_FILES_ACCOUNT]: { name: 'team@example.org', isPersonal: false, accountCapabilities: { [FILES]: {} } },
},
capabilities: { [FILES]: {} },
};
export interface FakeNode {
id: string;
parentId: string | null;
name: string;
blobId: string | null;
type: string | null;
size: number | null;
modified?: string;
}
export type FolderStyle = 'nodeType' | 'blob' | 'draft';
export interface FakeFilesOptions {
style?: FolderStyle;
getAll?: 'cut' | 'refuse';
queryOmitsFolders?: boolean;
}
class FilesAccount {
readonly nodes = new Map<string, FakeNode>();
readonly coll = new Collection();
}
export class FakeFiles extends FakeJmap {
private readonly fileAccounts = new Map<string, FilesAccount>();
private readonly options: Required<FakeFilesOptions>;
private nextNode = 1;
readonly blobs = new Map<string, Uint8Array>();
/** In order, with the account. */
readonly downloads: Array<{ jmap: string; blobId: string }> = [];
downloading = 0;
mostAtOnce = 0;
/** How a body arrives. */
partBytes = 256 * 1024;
/** Blob ids whose body breaks after this many bytes, with how many more times it should. */
readonly failMidBody = new Map<string, { after: number; times: number }>();
/** Bytes each blob's body actually delivered, last time. */
readonly delivered = new Map<string, number>();
/** Called after each part of a body, to look at the store meanwhile. */
onPart: ((blobId: string) => void) | null = null;
/** Set to make a download hang until the transport is aborted. */
hangDownloads = false;
private readonly hanging = new Set<() => void>();
constructor(options: FakeFilesOptions = {}) {
super();
this.options = { style: 'nodeType', getAll: 'cut', queryOmitsFolders: false, ...options };
}
account(id: string = JMAP_ACCOUNT): FilesAccount {
let found = this.fileAccounts.get(id);
if (!found) {
found = new FilesAccount();
this.fileAccounts.set(id, found);
}
return found;
}
addFolder(name: string, parentId: string | null = null, id?: string, accountId: string = JMAP_ACCOUNT): FakeNode {
const account = this.account(accountId);
const node: FakeNode = { id: id ?? `n${this.nextNode++}`, parentId, name, blobId: null, type: null, size: null };
account.nodes.set(node.id, node);
account.coll.push('created', node.id);
return node;
}
addFile(name: string, parentId: string | null = null, content: Uint8Array | number = 10, extra: Partial<FakeNode> = {}, accountId: string = JMAP_ACCOUNT): FakeNode {
const account = this.account(accountId);
const id = extra.id ?? `n${this.nextNode++}`;
const blobId = extra.blobId ?? `blob-${id}`;
const data = typeof content === 'number' ? new Uint8Array(content).fill(id.length) : content;
this.blobs.set(blobId, data);
const node: FakeNode = { id, parentId, name, blobId, type: 'text/plain', size: data.length, ...extra };
account.nodes.set(id, node);
account.coll.push('created', id);
return node;
}
/** A new version of the file: new blob, same node. */
replaceContent(id: string, content: Uint8Array, accountId: string = JMAP_ACCOUNT): string {
const account = this.account(accountId);
const node = account.nodes.get(id) as FakeNode;
const blobId = `${node.blobId as string}-v`;
this.blobs.set(blobId, content);
node.blobId = blobId;
node.size = content.length;
account.coll.push('updated', id);
return blobId;
}
renameNode(id: string, name: string, accountId: string = JMAP_ACCOUNT): void {
const account = this.account(accountId);
(account.nodes.get(id) as FakeNode).name = name;
account.coll.push('updated', id);
}
removeNode(id: string, accountId: string = JMAP_ACCOUNT): void {
const account = this.account(accountId);
account.nodes.delete(id);
account.coll.push('destroyed', id);
}
private wire(node: FakeNode, properties: string[] | undefined): Record<string, unknown> {
const isFolder = node.blobId === null;
const full: Record<string, unknown> = { id: node.id, parentId: node.parentId, name: node.name, blobId: node.blobId, size: node.size, created: '2026-10-01T00:00:00Z', modified: node.modified ?? '2026-10-01T00:00:00Z' };
if (this.options.style === 'draft') {
full['@type'] = isFolder ? 'Directory' : 'File';
full.mediaType = node.type;
} else {
full.type = node.type;
if (this.options.style === 'nodeType') full.nodeType = isFolder ? 'directory' : 'file';
}
if (!properties) return full;
const out: Record<string, unknown> = { id: node.id };
for (const p of properties) if (p in full) out[p] = full[p];
return out;
}
protected override handle(name: string, args: Record<string, unknown>): Record<string, unknown> {
if (!name.startsWith('FileNode/')) return super.handle(name, args);
const account = this.account(String(args.accountId));
switch (name) {
case 'FileNode/changes':
return changesFor(account.coll, args);
case 'FileNode/get': {
const ids = args.ids as string[] | null;
if (ids && ids.length > 500) throw Object.assign(new Error('requestTooLarge'), { jmapError: 'requestTooLarge' });
if (!ids && account.nodes.size > 500 && this.options.getAll === 'refuse') {
throw Object.assign(new Error('requestTooLarge'), { jmapError: 'requestTooLarge' });
}
const chosen = ids ?? [...account.nodes.keys()].slice(0, 500);
const properties = args.properties as string[] | undefined;
return {
accountId: args.accountId,
state: account.coll.state(),
list: chosen.filter((id) => account.nodes.has(id)).map((id) => this.wire(account.nodes.get(id) as FakeNode, properties)),
notFound: chosen.filter((id) => !account.nodes.has(id)),
};
}
case 'FileNode/query': {
const filter = (args.filter ?? {}) as { parentId?: string | null };
// Measured on both servers: this fails the whole request.
if (filter.parentId === null) throw new TransportError('http', 'JMAP request failed: 400', 400, 'urn:ietf:params:jmap:error:notRequest');
let list = [...account.nodes.values()];
if (this.options.queryOmitsFolders) list = list.filter((n) => n.blobId !== null);
if (filter.parentId) list = list.filter((n) => n.parentId === filter.parentId);
let position = Number(args.position ?? 0);
if (args.anchor !== undefined) {
const at = list.findIndex((n) => n.id === args.anchor);
if (at < 0) throw Object.assign(new Error('anchorNotFound'), { jmapError: 'anchorNotFound' });
position = at + Number(args.anchorOffset ?? 0);
}
const limit = Math.min(Number(args.limit ?? 5000) || 5000, 5000);
return {
accountId: args.accountId,
queryState: account.coll.state(),
canCalculateChanges: true,
position,
ids: list.slice(position, position + limit).map((n) => n.id),
...(args.calculateTotal ? { total: list.length } : {}),
};
}
default:
throw Object.assign(new Error(name), { jmapError: 'unknownMethod' });
}
}
abort(): void {
for (const stop of this.hanging) stop();
}
override async downloadStream(jmap: string, blobId: string, _type: string, _name: string, sink: (part: Uint8Array) => void): Promise<void> {
if (this.offline) throw new TransportError('network', 'Failed to fetch');
this.downloads.push({ jmap, blobId });
this.downloading += 1;
this.mostAtOnce = Math.max(this.mostAtOnce, this.downloading);
try {
if (this.hangDownloads) {
await new Promise<void>((resolve) => this.hanging.add(resolve));
throw new TransportError('network', 'Aborted');
}
const data = this.blobs.get(blobId);
if (!data) throw new TransportError('http', 'Download failed: 404', 404);
const failure = this.failMidBody.get(blobId);
this.delivered.set(blobId, 0);
for (let at = 0; at < data.length; at += this.partBytes) {
await Promise.resolve();
if (failure && failure.times > 0 && at >= failure.after) {
failure.times -= 1;
throw new TransportError('network', 'Connection reset', undefined, undefined, undefined, true);
}
const part = data.subarray(at, at + this.partBytes);
this.delivered.set(blobId, at + part.length);
sink(part);
this.onPart?.(blobId);
}
} finally {
this.downloading -= 1;
}
}
}
+5 -1
View File
@@ -286,10 +286,14 @@ export class FakeJmap implements JmapTransport {
return { methodResponses: done };
}
async download(): Promise<Uint8Array> {
async download(_jmap: string, _blobId: string, _type: string, _name: string): Promise<Uint8Array> {
return new Uint8Array();
}
async downloadStream(jmap: string, blobId: string, type: string, name: string, sink: (part: Uint8Array) => void): Promise<void> {
sink(await this.download(jmap, blobId, type, name));
}
async upload(): Promise<{ blobId: string; type: string; size: number }> {
return { blobId: 'uploaded', type: 'application/octet-stream', size: 0 };
}

Some files were not shown because too many files have changed in this diff Show More