+
+
+ Proofreading is required, but no proofreading plugin is forced on, so users can turn the plugin off under
+ Settings > Add-ons and the check with it. Forcing a plugin on applies to everyone, not only this scope.
+
+
+ For a fully mandatory setup: force the plugin on, set Proofreading to "On, required" and, in the plugin's
+ settings, turn off users changing its language and rules. A plugin that reaches a server on your network needs that
+ address in PLUGIN_PROXY_PRIVATE_ORIGINS.
+
+
+ );
+}
diff --git a/lib/__tests__/proofreading-admin-policy.test.ts b/lib/__tests__/proofreading-admin-policy.test.ts
new file mode 100644
index 000000000..36890baf2
--- /dev/null
+++ b/lib/__tests__/proofreading-admin-policy.test.ts
@@ -0,0 +1,120 @@
+import { describe, expect, it } from 'vitest';
+import { DEFAULT_POLICY, type SettingsPolicy } from '@/lib/admin/types';
+import { resolvePolicy, type PolicySubject } from '@/lib/admin/policy-scopes';
+import { checkerAccess, proofreadingAccess, type PolicyView } from '@/lib/proofreading/gate';
+import {
+ checkersToForce, pluginProofreadingState, pluginRestrictionFor, proofreadingPolicyFor, proofreadingState,
+ type ProofreadingState,
+} from '@/lib/admin/proofreading-policy';
+
+// The four admin states as the Organisation > Proofreading page writes
+// them, for everyone and for one group, resolved the way the client
+// resolves the policy for an account, and what a user then gets.
+
+const SALES: PolicySubject = { email: 'anna@example.org', groups: ['sales@example.org'] };
+const OTHER: PolicySubject = { email: 'bob@example.org', groups: [] };
+
+function everyone(state: ProofreadingState): SettingsPolicy {
+ const { restriction, preset } = proofreadingPolicyFor(state);
+ return {
+ ...DEFAULT_POLICY,
+ restrictions: restriction ? { proofreading: restriction } : {},
+ defaults: { proofreading: preset },
+ };
+}
+
+function scoped(state: ProofreadingState, base: SettingsPolicy = DEFAULT_POLICY): SettingsPolicy {
+ const { restriction, preset } = proofreadingPolicyFor(state);
+ return {
+ ...base,
+ scopes: [{
+ id: 'sales', label: 'Sales', match: { groups: ['sales@example.org'] },
+ policy: { restrictions: { proofreading: restriction ?? {} }, defaults: { proofreading: preset } },
+ }],
+ };
+}
+
+function viewFor(policy: SettingsPolicy, subject: PolicySubject): PolicyView {
+ const resolved = resolvePolicy(policy, subject).policy;
+ return { restrictions: resolved.restrictions, defaults: resolved.defaults as Record, settled: true };
+}
+
+/** A user who never touched the setting starts at the policy default. */
+const freshUser = (view: PolicyView) => ({ proofreading: view.defaults.proofreading === true, proofreadingPlugins: {} });
+
+describe('the four states for everyone', () => {
+ it('off and locked: nothing runs and the setting is hidden, whatever the user stored', () => {
+ const view = viewFor(everyone('off-locked'), OTHER);
+ expect(proofreadingAccess(view, { proofreading: true, proofreadingPlugins: {} })).toMatchObject({ on: false, lockedOff: true, hidden: true });
+ expect(checkerAccess(view, { proofreading: true, proofreadingPlugins: { lt: true } }, 'lt').on).toBe(false);
+ });
+
+ it('off by default: off until the user turns it on', () => {
+ const view = viewFor(everyone('off'), OTHER);
+ expect(proofreadingAccess(view, freshUser(view))).toMatchObject({ on: false, forced: false, lockedOff: false });
+ expect(checkerAccess(view, { proofreading: true, proofreadingPlugins: {} }, 'lt').on).toBe(true);
+ });
+
+ it('on by default: on until the user turns it off', () => {
+ const view = viewFor(everyone('on'), OTHER);
+ expect(proofreadingAccess(view, freshUser(view))).toMatchObject({ on: true, forced: false });
+ expect(checkerAccess(view, { proofreading: false, proofreadingPlugins: {} }, 'lt').on).toBe(false);
+ });
+
+ it('on and locked: on for every checker, also over a stored "off"', () => {
+ const view = viewFor(everyone('on-locked'), OTHER);
+ expect(proofreadingAccess(view, { proofreading: false, proofreadingPlugins: {} })).toMatchObject({ on: true, forced: true });
+ expect(checkerAccess(view, { proofreading: false, proofreadingPlugins: { lt: false } }, 'lt')).toMatchObject({ on: true, forced: true });
+ });
+});
+
+describe('the four states for one group', () => {
+ it('apply to its members and leave everyone else alone', () => {
+ for (const state of ['off-locked', 'on', 'on-locked'] as const) {
+ const policy = scoped(state);
+ const member = viewFor(policy, SALES);
+ const other = viewFor(policy, OTHER);
+ expect(proofreadingState(member.restrictions.proofreading, member.defaults.proofreading)).toBe(state);
+ expect(proofreadingState(other.restrictions.proofreading, other.defaults.proofreading)).toBe('off');
+ }
+ });
+
+ it('a group can lift a lock everyone has', () => {
+ const policy = scoped('on', everyone('off-locked'));
+ const member = viewFor(policy, SALES);
+ expect(proofreadingAccess(member, freshUser(member))).toMatchObject({ on: true, lockedOff: false });
+ expect(proofreadingAccess(viewFor(policy, OTHER), { proofreading: true, proofreadingPlugins: {} })).toMatchObject({ lockedOff: true });
+ });
+
+ it('a group can require it while everyone else chooses', () => {
+ const policy = scoped('on-locked', everyone('off'));
+ expect(checkerAccess(viewFor(policy, SALES), { proofreading: false, proofreadingPlugins: {} }, 'lt').forced).toBe(true);
+ expect(checkerAccess(viewFor(policy, OTHER), { proofreading: false, proofreadingPlugins: {} }, 'lt').on).toBe(false);
+ });
+});
+
+describe('per plugin', () => {
+ it('round-trips the plugin states', () => {
+ for (const s of ['follow', 'off-locked', 'on-locked'] as const) expect(pluginProofreadingState(pluginRestrictionFor(s))).toBe(s);
+ });
+});
+
+describe('checkersToForce', () => {
+ const lt = { id: 'lt', enabled: true };
+ const ai = { id: 'ai', enabled: true };
+
+ it('asks for nothing unless proofreading is required', () => {
+ expect(checkersToForce('on', {}, [lt, ai])).toEqual([]);
+ expect(checkersToForce('off-locked', { lt: 'on-locked' }, [lt])).toEqual([]);
+ });
+
+ it('offers the checkers when required and none is forced, and stops once one is', () => {
+ expect(checkersToForce('on-locked', {}, [lt, ai])).toEqual(['lt', 'ai']);
+ expect(checkersToForce('on-locked', {}, [{ ...lt, forceEnabled: true }, ai])).toEqual([]);
+ });
+
+ it('asks for a plugin required on its own', () => {
+ expect(checkersToForce('off', { lt: 'on-locked' }, [lt, ai])).toEqual(['lt']);
+ expect(checkersToForce('off', { lt: 'on-locked' }, [{ ...lt, forceEnabled: true }])).toEqual([]);
+ });
+});
diff --git a/lib/admin/admin-nav.ts b/lib/admin/admin-nav.ts
index 4a93f4f09..c0d5edf9a 100644
--- a/lib/admin/admin-nav.ts
+++ b/lib/admin/admin-nav.ts
@@ -23,6 +23,7 @@ export const ADMIN_PAGE_IDS = [
'org-security',
'org-sharing',
'org-labels',
+ 'org-proofreading',
'org-calendars',
'org-notices',
'org-devices',
@@ -200,6 +201,16 @@ export const ADMIN_PAGES: readonly AdminPageDef[] = [
{ id: 'labels', title: 'Classification labels', keywords: 'labels presets default required header prefix colour' },
],
},
+ {
+ id: 'org-proofreading',
+ label: 'Proofreading',
+ description: 'Spelling and grammar checks while users write: required, off, or their choice',
+ group: 'organisation',
+ keywords: 'proofreading spelling grammar spell check languagetool writing language mandatory required',
+ sections: [
+ { id: 'proofreading', title: 'Proofreading', keywords: 'checker plugin required lock language force' },
+ ],
+ },
{
id: 'org-calendars',
label: 'Calendars',
diff --git a/lib/admin/proofreading-policy.ts b/lib/admin/proofreading-policy.ts
new file mode 100644
index 000000000..e7335c236
--- /dev/null
+++ b/lib/admin/proofreading-policy.ts
@@ -0,0 +1,78 @@
+// The admin's choice for proofreading, as the four states the Organisation >
+// Proofreading page offers, and how each is written to the settings policy
+// (lib/proofreading/gate.ts reads it back on the client):
+//
+// off-locked restriction { locked, value: false, hidden } no checker runs, the setting is hidden
+// off default false users turn it on
+// on default true users may turn it off
+// on-locked restriction { locked, value: true } required, users can't turn it off
+//
+// Per checker plugin (`proofreading.`): follow the setting above,
+// off and locked, or on and required.
+
+import type { SettingRestriction } from './types';
+
+export type ProofreadingState = 'off-locked' | 'off' | 'on' | 'on-locked';
+export type PluginProofreadingState = 'follow' | 'off-locked' | 'on-locked';
+
+export const PROOFREADING_STATES: readonly { value: ProofreadingState; label: string }[] = [
+ { value: 'off-locked', label: 'Off, locked (hidden for users)' },
+ { value: 'off', label: 'Off by default, users can turn it on' },
+ { value: 'on', label: 'On by default, users can turn it off' },
+ { value: 'on-locked', label: 'On, required' },
+];
+
+export const PLUGIN_STATES: readonly { value: PluginProofreadingState; label: string }[] = [
+ { value: 'follow', label: 'Follow the setting above' },
+ { value: 'off-locked', label: 'Off, locked' },
+ { value: 'on-locked', label: 'On, required' },
+];
+
+/** The state a restriction and default stand for. */
+export function proofreadingState(restriction: SettingRestriction | undefined, preset: unknown): ProofreadingState {
+ if (restriction?.locked) return restriction.value === true ? 'on-locked' : 'off-locked';
+ return preset === true ? 'on' : 'off';
+}
+
+/** What to store for a state: the restriction and the default. */
+export function proofreadingPolicyFor(state: ProofreadingState): { restriction: SettingRestriction | undefined; preset: boolean } {
+ switch (state) {
+ case 'off-locked': return { restriction: { locked: true, value: false, hidden: true }, preset: false };
+ case 'off': return { restriction: undefined, preset: false };
+ case 'on': return { restriction: undefined, preset: true };
+ case 'on-locked': return { restriction: { locked: true, value: true }, preset: true };
+ }
+}
+
+export function pluginProofreadingState(restriction: SettingRestriction | undefined): PluginProofreadingState {
+ if (!restriction?.locked) return 'follow';
+ return restriction.value === true ? 'on-locked' : 'off-locked';
+}
+
+export function pluginRestrictionFor(state: PluginProofreadingState): SettingRestriction | undefined {
+ if (state === 'follow') return undefined;
+ return { locked: true, value: state === 'on-locked' };
+}
+
+/**
+ * Whether proofreading is required somewhere without a checker plugin the
+ * admin forced on: users could then turn the plugin off in Add-ons, and with
+ * it the check. Returns the checker plugins to offer forcing on.
+ */
+export function checkersToForce(
+ main: ProofreadingState,
+ pluginStates: Record,
+ checkers: readonly { id: string; enabled: boolean; forceEnabled?: boolean }[],
+): string[] {
+ const forced = (id: string) => checkers.some((c) => c.id === id && c.enabled && c.forceEnabled);
+ const needed = new Set();
+ for (const [id, state] of Object.entries(pluginStates)) {
+ if (state === 'on-locked' && main !== 'off-locked' && !forced(id)) needed.add(id);
+ }
+ // The setting required as a whole: one checker forced on is enough.
+ const candidates = checkers.filter((c) => (pluginStates[c.id] ?? 'follow') === 'follow');
+ if (main === 'on-locked' && !candidates.some((c) => forced(c.id))) {
+ for (const c of candidates) needed.add(c.id);
+ }
+ return [...needed];
+}
diff --git a/lib/admin/restriction-keys.ts b/lib/admin/restriction-keys.ts
index 3b6c75525..32ccb4119 100644
--- a/lib/admin/restriction-keys.ts
+++ b/lib/admin/restriction-keys.ts
@@ -84,6 +84,9 @@ export const RESTRICTION_KEYS: readonly RestrictionKeyDef[] = [
{ key: 'autoSelectReplyIdentity', label: 'Auto-select Reply Identity', category: 'Composer', type: 'boolean', page: 'policy', defaultValue: false },
{ key: 'replyIdentityMatch', label: 'Reply Identity Matching', category: 'Composer', type: 'enum', allowedValues: ['exact', 'domain'], page: 'policy', defaultValue: 'domain' },
{ key: 'plainTextMode', label: 'Plain Text Only', category: 'Composer', type: 'boolean', page: 'policy', defaultValue: false },
+ // Set on Organisation > Proofreading, with per-plugin keys `proofreading.` (lib/proofreading/gate.ts).
+ { key: 'proofreading', label: 'Proofreading', description: 'Locked off, no proofreading plugin gets any text; locked on, it checks in every composer.', category: 'Composer', type: 'boolean', page: 'org-proofreading' },
+ { key: 'writingLanguage', label: 'Writing language', category: 'Composer', type: 'enum', page: 'org-proofreading' },
{ key: 'emailNotificationsEnabled', label: 'Email Notifications', category: 'Notifications', type: 'boolean', page: 'policy', defaultValue: true },
{ key: 'calendarNotificationsEnabled', label: 'Calendar Notifications', category: 'Notifications', type: 'boolean', page: 'policy', defaultValue: true },
{ key: 'pushNotifyInboxOnly', label: 'Inbox-only Push Notifications', category: 'Notifications', type: 'boolean', page: 'policy', defaultValue: false },
diff --git a/stores/__tests__/settings-store-proofreading.test.ts b/stores/__tests__/settings-store-proofreading.test.ts
new file mode 100644
index 000000000..1eea474c8
--- /dev/null
+++ b/stores/__tests__/settings-store-proofreading.test.ts
@@ -0,0 +1,74 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const apiFetch = vi.hoisted(() => vi.fn());
+vi.mock('@/lib/browser-navigation', () => ({ apiFetch }));
+
+import { useSettingsStore } from '../settings-store';
+import { usePolicyStore } from '../policy-store';
+import { DEFAULT_POLICY, type SettingsPolicy } from '@/lib/admin/types';
+import { checkerAccess } from '@/lib/proofreading/gate';
+import { currentPolicyView } from '@/components/email/proofreading/use-proofreading';
+
+// Proofreading's settings: synced like the others, cleaned when they come in,
+// and an admin lock wins over an old synced value or one set from the console.
+
+const settings = () => useSettingsStore.getState();
+const loadPolicy = (patch: Partial) =>
+ usePolicyStore.setState({ policy: { ...DEFAULT_POLICY, ...patch }, loaded: true, partial: false, subjectPending: false });
+
+beforeEach(() => {
+ localStorage.clear();
+ apiFetch.mockReset();
+ usePolicyStore.setState({ policy: { ...DEFAULT_POLICY }, loaded: false });
+ settings().disableSync();
+ settings().resetToDefaults();
+});
+
+describe('proofreading settings', () => {
+ it('starts off, with automatic language detection', () => {
+ expect(settings().proofreading).toBe(false);
+ expect(settings().writingLanguage).toBe('auto');
+ expect(settings().proofreadingPlugins).toEqual({});
+ });
+
+ it('drops odd values from an import', () => {
+ settings().importSettings(JSON.stringify({
+ proofreading: true,
+ proofreadingPlugins: { lt: true, ai: 'yes', __proto__: { x: true } },
+ writingLanguage: '