From 680542ed9f742b1d5cb95319632d7d7e06c5834f Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sun, 27 Sep 2026 23:17:43 -0700 Subject: [PATCH] Switch IMAP, POP3 and ManageSieve on and off one at a time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settings › Security › Hardening puts a switch on each of the three protocols' rows, beside its ports and a count of the accounts that used it in the last 30 days. Turning one off asks first, naming who used it lately and the ports that close; turning one back on is one click. The kill-all stays as "Turn off all legacy protocols…" with the full statement and the typed phrase, and the status card says which are off when only some are ("POP3 is off on this server. Mail apps can still use IMAP and ManageSieve, and send."), with "Turn them all back on". A listener that failed to reopen is retried by turning its protocol on again. Each tenant's page gets the same three switches (no ports: a tenant's switch closes none). A server administrator sees a protocol that is off server-wide as such, its switch disabled; inside a tenant the server refuses it and the toast says which protocol. Reads the per-protocol properties from inbuxa-server's feature/per-protocol-switches; from an older server, legacyProtocols stands for all three, so the page keeps working. Tested: unit tests (reading each switch and an older server's one, per-row ports while off and on, one protocol's recent users); typecheck and lint; and in headless Chrome against a local server on that branch: POP3 off through its dialog, the page reading "POP3 is off on this server" with its port closed, and back on; ManageSieve off and on for a tenant. --- .../hardening/LegacyProtocolsPage.tsx | 207 +++++++++++++----- .../hardening/TenantLegacyProtocols.tsx | 132 +++++++++-- src/features/hardening/parts.tsx | 152 ++++++++++++- src/features/hardening/protocolPolicy.test.ts | 53 ++++- src/features/hardening/protocolPolicy.ts | 96 ++++++-- 5 files changed, 533 insertions(+), 107 deletions(-) diff --git a/src/features/hardening/LegacyProtocolsPage.tsx b/src/features/hardening/LegacyProtocolsPage.tsx index 403650a..28fb015 100644 --- a/src/features/hardening/LegacyProtocolsPage.tsx +++ b/src/features/hardening/LegacyProtocolsPage.tsx @@ -6,12 +6,14 @@ /** * INBUXA: Settings › Security › Hardening, the server-wide legacy mail - * protocols switch (legacy-protocols spec, LP-16, LP-17, LP-20, LP-21). + * protocols switches (legacy-protocols spec, LP-16, LP-17, LP-20, LP-21, and + * "one switch per protocol"). * - * Nobody should turn this on by accident or without understanding it, so the - * statement is shown in full before the switch moves, and turning it on takes - * a typed phrase. Turning it back on is one click: undoing a restriction must - * never be the hard part. + * Each of IMAP, POP3 and ManageSieve has a switch on its row: turning one off + * asks first, naming who used it lately and the ports that close. Turning + * them all off at once shows the statement in full and takes a typed phrase. + * Turning anything back on is one click: undoing a restriction must never be + * the hard part. */ import { useCallback, useEffect, useState } from 'react'; @@ -25,13 +27,19 @@ import { cn } from '@/lib/utils'; import { describeListener, fetchProtocolPolicy, + offProtocols, PolicyUnavailable, + PROTOCOL_LABELS, protocolRows, + SWITCHED, updateProtocolPolicy, + usersOf, type ProtocolPolicy, type ProtocolRow, + type SwitchedProtocol, + type SwitchUpdate, } from './protocolPolicy'; -import { ConfirmTurnOff, ImpactPanel, Statement } from './parts'; +import { ConfirmTurnOff, ImpactPanel, ProtocolSwitch, Statement } from './parts'; type Load = { kind: 'loading' } | { kind: 'ready'; policy: ProtocolPolicy } | { kind: 'error'; message: string }; @@ -71,12 +79,12 @@ export function LegacyProtocolsPage() { }, [loaded]); const turn = useCallback( - async (legacyProtocols: 'enabled' | 'disabled') => { + async (update: SwitchUpdate) => { setBusy(true); try { - // Only legacyProtocols is sent. The server may still report closeSubmission - // overruled by the SMTP lock (LP-21), which the selector already shows. - await updateProtocolPolicy({ legacyProtocols }); + // Only switches are sent. The server may still report closeSubmission + // overruled by the SMTP lock (LP-21), which the table already shows. + await updateProtocolPolicy(update); setConfirming(false); await refresh(); } catch (e) { @@ -102,11 +110,17 @@ export function LegacyProtocolsPage() { } const { policy } = load; + // All three off: the kill-all's state. const off = policy.legacyProtocols === 'disabled'; - // What closes: what already did while the switch is off, what would otherwise. + // What closes: what already did while all are off, what would otherwise. const listeners = off ? policy.savedListeners : policy.wouldClose; - // Enabled with listeners still saved: some could not be put back (LP-5). - const stranded = off ? [] : policy.savedListeners; + // A protocol that is on with listeners still saved: some could not be put + // back (LP-5). Trying again turns those protocols on again. + const strandedOn = new Set( + SWITCHED.filter((p) => policy.switches[p] === 'enabled' && policy.savedListeners.some((l) => l.protocol === p)), + ); + const stranded = policy.savedListeners.filter((l) => strandedOn.has(l.protocol as SwitchedProtocol)); + const retry: SwitchUpdate = Object.fromEntries([...strandedOn].map((p) => [p, 'enabled'])); return (
@@ -115,12 +129,17 @@ export function LegacyProtocolsPage() {

{t( 'legacyProtocols.subtitle', - 'Turn off IMAP, POP3, ManageSieve and sending from mail apps, so that only inbuxa webmail and JMAP apps can reach this server.', + 'Turn off IMAP, POP3 or ManageSieve one at a time, or all of them with sending from mail apps, so that only inbuxa webmail and JMAP apps can reach this server.', )}

- turn('enabled')} /> + void turn({ legacyProtocols: 'enabled' })} + /> {stranded.length > 0 && (
@@ -138,7 +157,7 @@ export function LegacyProtocolsPage() { {stranded.map(describeListener).join(', ')}

{canUpdate && ( - @@ -148,7 +167,13 @@ export function LegacyProtocolsPage() {
)} - + void turn({ [protocol]: value })} + /> {!off && policy.recentLegacyUse && } @@ -157,14 +182,14 @@ export function LegacyProtocolsPage() { {!off && canUpdate && !confirming && ( )} {!off && confirming && ( void turn('disabled')} + onConfirm={() => void turn({ legacyProtocols: 'disabled' })} onCancel={() => { setConfirming(false); }} @@ -174,21 +199,29 @@ export function LegacyProtocolsPage() { ); } +/** Protocol names as a sentence: "IMAP", "IMAP and POP3", "IMAP, POP3 and ManageSieve". */ +function listNames(protocols: SwitchedProtocol[], and: string): string { + const names = protocols.map((p) => PROTOCOL_LABELS[p] ?? p); + return names.length <= 1 ? (names[0] ?? '') : `${names.slice(0, -1).join(', ')} ${and} ${names[names.length - 1]}`; +} + function StatusCard({ policy, - off, busy, canUpdate, onTurnOn, }: { policy: ProtocolPolicy; - off: boolean; busy: boolean; canUpdate: boolean; onTurnOn: () => void; }) { const { t } = useTranslation(); + const offList = offProtocols(policy.switches); + const off = policy.legacyProtocols === 'disabled'; + const some = offList.length > 0 && !off; const Icon = off ? ShieldCheck : ShieldOff; + const and = t('legacyProtocols.and', 'and'); return (
{off ? t('legacyProtocols.statusOff', 'Legacy mail protocols are off on this server.') - : t('legacyProtocols.statusOn', 'Legacy mail protocols are on.')} + : some + ? t('legacyProtocols.statusSome', { + count: offList.length, + list: listNames(offList, and), + defaultValue_one: '{{list}} is off on this server.', + defaultValue_other: '{{list}} are off on this server.', + }) + : t('legacyProtocols.statusOn', 'Legacy mail protocols are on.')}

{off ? t('legacyProtocols.statusOffBody', 'Only inbuxa webmail and JMAP apps can sign in.') - : t('legacyProtocols.statusOnBody', 'Mail apps can use IMAP, POP3 and ManageSieve.')} + : some + ? t('legacyProtocols.statusSomeBody', 'Mail apps can still use {{list}}, and send.', { + list: listNames( + SWITCHED.filter((p) => !offList.includes(p)), + and, + ), + }) + : t('legacyProtocols.statusOnBody', 'Mail apps can use IMAP, POP3 and ManageSieve.')} {policy.changedAt !== null && ( <> {' '} @@ -219,64 +266,126 @@ function StatusCard({

- {off && canUpdate && ( + {offList.length > 0 && canUpdate && ( )} ); } -function ProtocolTable({ rows, off }: { rows: ProtocolRow[]; off: boolean }) { +function ProtocolTable({ + policy, + rows, + busy, + canUpdate, + onTurn, +}: { + policy: ProtocolPolicy; + rows: ProtocolRow[]; + busy: boolean; + canUpdate: boolean; + onTurn: (protocol: SwitchedProtocol, value: 'enabled' | 'disabled') => void; +}) { const { t } = useTranslation(); + const allOff = policy.legacyProtocols === 'disabled'; + const recent = policy.recentLegacyUse; + const switched = (key: string): key is SwitchedProtocol => (SWITCHED as readonly string[]).includes(key); const stateText = (row: ProtocolRow) => { switch (row.state) { case 'locked': - return t('legacyProtocols.rowLocked', 'Locked open'); + return row.key === 'submission' + ? allOff + ? t('legacyProtocols.rowRefused', 'Port open, sign-in refused') + : t('legacyProtocols.rowSubmission', 'On; sign-in refused only with all three off') + : t('legacyProtocols.rowLocked', 'Locked open'); case 'refused': return t('legacyProtocols.rowRefused', 'Port open, sign-in refused'); case 'closes': - if (row.ports.length === 0) return t('legacyProtocols.rowNoListener', 'No listener'); - return off ? t('legacyProtocols.rowClosed', 'Closed') : t('legacyProtocols.rowWouldClose', 'Closes'); + if (row.off) { + return row.ports.length > 0 + ? t('legacyProtocols.rowClosed', 'Off, ports closed') + : t('legacyProtocols.rowOff', 'Off'); + } + return row.ports.length === 0 + ? t('legacyProtocols.rowNoListener', 'On, no listener') + : t('legacyProtocols.rowOn', 'On'); } }; return ( -
+
- + + + - {rows.map((row) => ( - - - - - - ))} + {rows.map((row) => { + const users = (switched(row.key) || row.key === 'submission') && recent ? usersOf(recent, row.key) : null; + return ( + + + + + + + + ); + })}
{t('legacyProtocols.colProtocol', 'Protocol')} {t('legacyProtocols.colPorts', 'Ports')} - {off ? t('legacyProtocols.colNow', 'Now') : t('legacyProtocols.colWhenOff', 'When turned off')} - {t('legacyProtocols.colUsed', 'Used lately')}{t('legacyProtocols.colNow', 'Now')}{t('legacyProtocols.colSwitch', 'Switch')}
{row.label} - {row.ports.length > 0 ? row.ports.join(', ') : '—'} - - - {row.state === 'locked' && } - {stateText(row)} - -
{row.label} + {row.ports.length > 0 ? row.ports.join(', ') : '—'} + + {users === null + ? '—' + : users.length === 0 + ? t('legacyProtocols.usedNobody', 'Nobody') + : t('legacyProtocols.usedCount', { + count: users.length, + defaultValue_one: '1 account', + defaultValue_other: '{{count}} accounts', + })} + + + {row.state === 'locked' && } + {stateText(row)} + + + {switched(row.key) && canUpdate && ( + onTurn(row.key as SwitchedProtocol, 'enabled')} + onTurnOff={() => onTurn(row.key as SwitchedProtocol, 'disabled')} + /> + )} +

{t( 'legacyProtocols.lockNote', 'Incoming mail (SMTP) and inbuxa webmail (JMAP) are locked open: closing them would stop mail arriving and lock everyone out, including you.', + )}{' '} + {t( + 'legacyProtocols.submissionNote', + 'Sending from mail apps goes on while any of IMAP, POP3 or ManageSieve is on.', )}

diff --git a/src/features/hardening/TenantLegacyProtocols.tsx b/src/features/hardening/TenantLegacyProtocols.tsx index 14f122c..4d99d8e 100644 --- a/src/features/hardening/TenantLegacyProtocols.tsx +++ b/src/features/hardening/TenantLegacyProtocols.tsx @@ -5,14 +5,15 @@ */ /** - * INBUXA: one tenant's legacy mail protocols switch, on the tenant's page - * (legacy-protocols spec, LP-9 to LP-18 at tenant scope). + * INBUXA: one tenant's legacy mail protocols switches, on the tenant's page + * (legacy-protocols spec, LP-9 to LP-18 at tenant scope, and "one switch per + * protocol"). * - * It closes no port -- other tenants share them (LP-13) -- so the statement - * names no listener and carries no firewall note. It refuses sign-in over - * legacy protocols on the tenant's domains. Turning it off takes the typed - * phrase; turning it back on is one click, which the server refuses while - * it has legacy protocols off itself (LP-9), and says so. + * They close no port -- other tenants share them (LP-13) -- so nothing here + * names a listener or carries a firewall note. They refuse sign-in over each + * protocol on the tenant's domains. One protocol off asks first; all of them + * off takes the typed phrase; back on is one click, which the server refuses + * while it has that protocol off itself (LP-9), and says which. */ import { useCallback, useEffect, useState } from 'react'; @@ -23,14 +24,29 @@ import { useAccountStore } from '@/stores/accountStore'; import { toast } from '@/hooks/use-toast'; import { cn } from '@/lib/utils'; import { getAccountId, jmapGet } from '@/services/jmap/client'; -import { fetchTenantPolicy, PolicyUnavailable, updateTenantPolicy, type TenantPolicy } from './protocolPolicy'; -import { ConfirmTurnOff, ImpactPanel, Statement } from './parts'; +import { + fetchProtocolPolicy, + fetchTenantPolicy, + offProtocols, + PolicyUnavailable, + PROTOCOL_LABELS, + SWITCHED, + updateTenantPolicy, + usersOf, + type Switches, + type SwitchUpdate, + type TenantPolicy, +} from './protocolPolicy'; +import { ConfirmTurnOff, ImpactPanel, ProtocolSwitch, Statement } from './parts'; export function TenantLegacyProtocols({ tenantId }: { tenantId: string }) { const { t } = useTranslation(); const canGet = useAccountStore((s) => s.hasObjectPermission('sysDomain', 'Get')); const canUpdate = useAccountStore((s) => s.hasObjectPermission('sysDomain', 'Update')); const [policy, setPolicy] = useState(null); + // The server's own switches, which a server administrator can read: a + // protocol off there is off for this tenant whatever its switch says. + const [server, setServer] = useState(null); const [organization, setOrganization] = useState(''); const [confirming, setConfirming] = useState(false); const [busy, setBusy] = useState(false); @@ -48,10 +64,21 @@ export function TenantLegacyProtocols({ tenantId }: { tenantId: string }) { [], ); + const loadServer = useCallback((signal?: AbortSignal) => { + fetchProtocolPolicy(signal) + .then((p) => { + if (!signal?.aborted) setServer(p.switches); + }) + // Inside a tenant the server's switches aren't readable; the server + // still refuses what they forbid, and says so. + .catch(() => {}); + }, []); + useEffect(() => { if (!canGet) return; const controller = new AbortController(); void loaded(fetchTenantPolicy(tenantId, controller.signal), controller.signal); + loadServer(controller.signal); // The organization's name, for the statement. jmapGet('x:Tenant', getAccountId('x:Tenant'), [tenantId], ['name'], controller.signal) .then((responses) => { @@ -60,13 +87,13 @@ export function TenantLegacyProtocols({ tenantId }: { tenantId: string }) { }) .catch(() => {}); return () => controller.abort(); - }, [tenantId, canGet, loaded]); + }, [tenantId, canGet, loaded, loadServer]); const turn = useCallback( - async (value: 'enabled' | 'disabled') => { + async (update: SwitchUpdate) => { setBusy(true); try { - await updateTenantPolicy(tenantId, value); + await updateTenantPolicy(tenantId, update); setConfirming(false); await loaded(fetchTenantPolicy(tenantId)); } catch (e) { @@ -84,7 +111,9 @@ export function TenantLegacyProtocols({ tenantId }: { tenantId: string }) { if (!policy) return null; const off = policy.legacyProtocols === 'disabled'; + const offList = offProtocols(policy.switches); const name = organization || t('legacyProtocols.thisOrganization', 'this organization'); + const scope = t('legacyProtocols.scopeTenant', 'everyone in {{organization}}', { organization: name }); return ( // Aligned with the tenant form beneath it. @@ -105,32 +134,91 @@ export function TenantLegacyProtocols({ tenantId }: { tenantId: string }) { 'Off for {{organization}}. Only inbuxa webmail and JMAP apps can sign in to its domains.', { organization: name }, ) - : t( - 'legacyProtocols.tenantOn', - 'On for {{organization}}. Mail apps can use IMAP, POP3 and ManageSieve on its domains.', - { organization: name }, - )} + : offList.length > 0 + ? t( + 'legacyProtocols.tenantSome', + 'Some are off for {{organization}}. Mail apps can still send, and use what is on.', + { organization: name }, + ) + : t( + 'legacyProtocols.tenantOn', + 'On for {{organization}}. Mail apps can use IMAP, POP3 and ManageSieve on its domains.', + { organization: name }, + )}

- {canUpdate && off && ( - )} {canUpdate && !off && !confirming && ( )} + + {!off && confirming && ( <> {policy.recentLegacyUse && } - void turn('disabled')} onCancel={() => setConfirming(false)} /> + void turn({ legacyProtocols: 'disabled' })} + onCancel={() => setConfirming(false)} + /> )} diff --git a/src/features/hardening/parts.tsx b/src/features/hardening/parts.tsx index 4a9d373..b471016 100644 --- a/src/features/hardening/parts.tsx +++ b/src/features/hardening/parts.tsx @@ -5,9 +5,10 @@ */ /** - * INBUXA: the parts the server's switch (Settings › Security › Hardening) and - * a tenant's switch (each tenant's page) share: the impact panel (LP-15), the - * statement (LP-16) and the typed confirmation (LP-17). + * INBUXA: the parts the server's switches (Settings › Security › Hardening) + * and a tenant's switches (each tenant's page) share: one protocol's switch + * with its confirmation, the impact panel (LP-15), the statement (LP-16) and + * the typed confirmation for the kill-all (LP-17). */ import { useState } from 'react'; @@ -16,19 +17,156 @@ import { Loader2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; +import { + AlertDialog, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; import { ago, CONFIRM_PHRASE, describeListener, impactEntries, phraseMatches, + type ImpactEntry, type PolicyListener, type RecentUse, } from './protocolPolicy'; +/** How many names the one-protocol confirmation lists before "and N more". */ +const NAMES_SHOWN = 8; + /** - * The typed confirmation to turn legacy protocols off (LP-17): the button - * stays disabled until the phrase matches exactly. + * One protocol's switch. Turning it on is one click; turning it off asks + * first, naming who used it lately and, at server scope, the ports that + * close. Turning off one protocol takes no typed phrase: that is kept for + * turning them all off at once (LP-17). + */ +export function ProtocolSwitch({ + label, + off, + disabled, + users, + ports, + scope, + onTurnOn, + onTurnOff, +}: { + label: string; + off: boolean; + disabled: boolean; + /** Who used it lately, or null when the server doesn't say. */ + users: ImpactEntry[] | null; + /** The ports that close, at server scope; null at tenant scope, where none do (LP-13). */ + ports: number[] | null; + /** Who it's turned off for: "everyone on this server", "everyone in Northwind". */ + scope: string; + onTurnOn: () => void; + onTurnOff: () => void; +}) { + const { t, i18n } = useTranslation(); + const [asking, setAsking] = useState(false); + const [now] = useState(() => Date.now()); + return ( + <> + (on ? onTurnOn() : setAsking(true))} + /> + + + + + {t('legacyProtocols.oneTitle', 'Turn off {{protocol}}?', { protocol: label })} + + +
+

+ {t( + 'legacyProtocols.oneLead', + 'Mail apps using {{protocol}} will stop working for {{scope}}. The other protocols, sending from mail apps, and inbuxa webmail are not affected.', + { protocol: label, scope }, + )} +

+ {users && users.length === 0 && ( +

+ {t('legacyProtocols.oneNobody', 'No account used {{protocol}} in the last 30 days.', { + protocol: label, + })} +

+ )} + {users && users.length > 0 && ( +
+

+ {t('legacyProtocols.oneUsers', { + count: users.length, + protocol: label, + defaultValue_one: '1 account used {{protocol}} in the last 30 days:', + defaultValue_other: '{{count}} accounts used {{protocol}} in the last 30 days:', + })} +

+
    + {users.slice(0, NAMES_SHOWN).map((user) => ( +
  • + {user.name}{' '} + · {ago(user.lastUsedAt, now, i18n.language)} +
  • + ))} +
+ {users.length > NAMES_SHOWN && ( +

+ {t('legacyProtocols.oneMore', 'and {{count}} more', { count: users.length - NAMES_SHOWN })} +

+ )} +
+ )} + {ports !== null && ( +

+ {ports.length > 0 + ? t( + 'legacyProtocols.onePorts', + 'Its ports close: {{ports}}. Your firewall and port forwarding are not changed.', + { + ports: ports.join(', '), + }, + ) + : t('legacyProtocols.oneNoPorts', 'No {{protocol}} listener is configured, so no port closes.', { + protocol: label, + })} +

+ )} +

{t('legacyProtocols.oneUndo', 'You can turn it back on at any time.')}

+
+
+
+ + {t('common.cancel', 'Cancel')} + + +
+
+ + ); +} + +/** + * The typed confirmation to turn all legacy protocols off at once (LP-17): + * the button stays disabled until the phrase matches exactly. */ export function ConfirmTurnOff({ busy, @@ -63,7 +201,7 @@ export function ConfirmTurnOff({