From b6b54d2ac02d8b84c7d1cabf4e27bb4444c2133d Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 21 Sep 2026 09:19:43 -0700 Subject: [PATCH] =?UTF-8?q?Settings=20=E2=80=BA=20Security=20=E2=80=BA=20H?= =?UTF-8?q?ardening:=20the=20legacy=20mail=20protocols=20switch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The screen for inbuxa:ProtocolPolicy, the server-wide switch that closes IMAP, POP3 and ManageSieve (legacy-protocols spec). Reached as CustomComponent/LegacyProtocols, which the server's schema places under Settings › Security; a server without that link never shows it. - The selector lists every mail protocol, with what the switch does to each and on which ports. SMTP and JMAP are shown locked, from the server's lockedProtocols rather than a list carried here, so unlocking later needs no admin release (LP-21). - The statement is shown in full before the switch moves and while it is off, with the listeners that close by name and port, and the note that firewall rules and port-forwards are the operator's to reconcile (LP-16, LP-20). - Turning it off takes the typed phrase "turn off legacy mail", matched exactly. Turning it back on is one click (LP-17). - Listeners that could not be reopened stay listed, with a Try again (LP-5). - A banner on the Security settings and the dashboard while it is off (LP-18). It stays silent on a server without the switch. Visible to whoever may see listeners, changeable by whoever may update them, matching the permissions the server checks. Not here yet: the impact panel (LP-15), which needs the server to record last use per protocol, and the tenant switch (LP-9 to LP-14). --- src/components/layout/MainContent.tsx | 18 + .../dashboard/components/DashboardView.tsx | 2 + .../hardening/LegacyProtocolsBanner.tsx | 52 +++ .../hardening/LegacyProtocolsPage.tsx | 385 ++++++++++++++++++ src/features/hardening/protocolPolicy.test.ts | 84 ++++ src/features/hardening/protocolPolicy.ts | 176 ++++++++ src/lib/layout.ts | 6 + 7 files changed, 723 insertions(+) create mode 100644 src/features/hardening/LegacyProtocolsBanner.tsx create mode 100644 src/features/hardening/LegacyProtocolsPage.tsx create mode 100644 src/features/hardening/protocolPolicy.test.ts create mode 100644 src/features/hardening/protocolPolicy.ts diff --git a/src/components/layout/MainContent.tsx b/src/components/layout/MainContent.tsx index ac394af..642eefb 100644 --- a/src/components/layout/MainContent.tsx +++ b/src/components/layout/MainContent.tsx @@ -16,6 +16,7 @@ import { DynamicList } from '@/components/lists/DynamicList'; import { DynamicForm } from '@/components/forms/DynamicForm'; import { DynamicViewPage } from '@/components/views/DynamicViewPage'; import { LoadingFallback } from '@/components/common/LoadingFallback'; +import { LegacyProtocolsBanner } from '@/features/hardening/LegacyProtocolsBanner'; import type { Schema } from '@/types/schema'; function lazyFeature(load: () => Promise, select: (module: M) => ComponentType

) { @@ -46,6 +47,10 @@ const ActionPage = lazyFeature( () => import('@/features/actions/ActionPage'), (m) => m.ActionPage, ); +const LegacyProtocolsPage = lazyFeature( + () => import('@/features/hardening/LegacyProtocolsPage'), + (m) => m.LegacyProtocolsPage, +); interface MainContentProps { viewName?: string; @@ -99,6 +104,10 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti if (componentName === 'LiveTracing') { return ; } + // INBUXA: Settings › Security › Hardening (legacy-protocols spec). + if (componentName === 'LegacyProtocols') { + return ; + } return (

Unknown component: {componentName} @@ -120,6 +129,15 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti } if (resolved.objectType.type === 'singleton') { + // INBUXA: the Security settings carry the legacy protocols banner (LP-18). + if (resolved.objectName === 'x:Security') { + return ( +
+ + +
+ ); + } return ; } diff --git a/src/features/dashboard/components/DashboardView.tsx b/src/features/dashboard/components/DashboardView.tsx index 1fac3fb..d3bef86 100644 --- a/src/features/dashboard/components/DashboardView.tsx +++ b/src/features/dashboard/components/DashboardView.tsx @@ -15,6 +15,7 @@ import { AlertCircle } from 'lucide-react'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { useSchemaStore } from '@/stores/schemaStore'; import type { Dashboard } from '../types/schema'; +import { LegacyProtocolsBanner } from '@/features/hardening/LegacyProtocolsBanner'; import { useDashboardStore } from '../stores/dashboardStore'; import { useLiveMetricsStore } from '../stores/liveMetricsStore'; import { useHistoryMetricsStore } from '../stores/historyMetricsStore'; @@ -123,6 +124,7 @@ export function DashboardView({ dashboardId, section }: DashboardViewProps) {
+
{dashboards.length > 1 && ( navigate(`/${section}/Dashboard/${id}`)}> diff --git a/src/features/hardening/LegacyProtocolsBanner.tsx b/src/features/hardening/LegacyProtocolsBanner.tsx new file mode 100644 index 0000000..ce1a3bf --- /dev/null +++ b/src/features/hardening/LegacyProtocolsBanner.tsx @@ -0,0 +1,52 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * INBUXA: the banner while legacy mail protocols are off (LP-18), on the + * Security settings and the dashboard. It says nothing when the switch is on, + * when the reader may not see the policy, or when the server has no policy. + */ + +import { useEffect, useState } from 'react'; +import { Link } from 'react-router-dom'; +import { useTranslation } from 'react-i18next'; +import { ShieldCheck } from 'lucide-react'; +import { useAccountStore } from '@/stores/accountStore'; +import { fetchProtocolPolicy } from './protocolPolicy'; + +export const LEGACY_PROTOCOLS_VIEW = 'CustomComponent/LegacyProtocols'; + +export function LegacyProtocolsBanner() { + const { t } = useTranslation(); + const canGet = useAccountStore((s) => s.hasObjectPermission('sysNetworkListener', 'Get')); + const [off, setOff] = useState(false); + + useEffect(() => { + if (!canGet) return; + const controller = new AbortController(); + fetchProtocolPolicy(controller.signal) + .then((policy) => setOff(policy.legacyProtocols === 'disabled')) + // A banner is not worth an error: an older server simply has no switch. + .catch(() => setOff(false)); + return () => controller.abort(); + }, [canGet]); + + if (!off) return null; + + return ( +
+ + + {t('legacyProtocols.bannerLead', 'Legacy mail protocols are')}{' '} + {t('legacyProtocols.bannerOff', 'off')}{' '} + {t('legacyProtocols.bannerTail', 'on this server. Only INBUXA webmail and JMAP apps can sign in.')} + + + {t('legacyProtocols.review', 'Review')} + +
+ ); +} diff --git a/src/features/hardening/LegacyProtocolsPage.tsx b/src/features/hardening/LegacyProtocolsPage.tsx new file mode 100644 index 0000000..7b3add4 --- /dev/null +++ b/src/features/hardening/LegacyProtocolsPage.tsx @@ -0,0 +1,385 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * INBUXA: Settings › Security › Hardening, the server-wide legacy mail + * protocols switch (legacy-protocols spec, LP-16, LP-17, LP-20, LP-21). + * + * 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. + */ + +import { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { AlertTriangle, Loader2, Lock, RotateCcw, ShieldCheck, ShieldOff } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { LoadingFallback } from '@/components/common/LoadingFallback'; +import { useAccountStore } from '@/stores/accountStore'; +import { toast } from '@/hooks/use-toast'; +import { cn } from '@/lib/utils'; +import { + CONFIRM_PHRASE, + describeListener, + fetchProtocolPolicy, + phraseMatches, + PolicyUnavailable, + protocolRows, + updateProtocolPolicy, + type PolicyListener, + type ProtocolPolicy, + type ProtocolRow, +} from './protocolPolicy'; + +type Load = { kind: 'loading' } | { kind: 'ready'; policy: ProtocolPolicy } | { kind: 'error'; message: string }; + +export function LegacyProtocolsPage() { + const { t } = useTranslation(); + const canUpdate = useAccountStore((s) => s.hasObjectPermission('sysNetworkListener', 'Update')); + const [load, setLoad] = useState({ kind: 'loading' }); + const [confirming, setConfirming] = useState(false); + const [typed, setTyped] = useState(''); + const [busy, setBusy] = useState(false); + + const loaded = useCallback( + (fetching: Promise, signal?: AbortSignal) => + fetching + .then((policy) => { + if (!signal?.aborted) setLoad({ kind: 'ready', policy }); + }) + .catch((e: unknown) => { + if (signal?.aborted) return; + setLoad({ + kind: 'error', + message: + e instanceof PolicyUnavailable + ? t('legacyProtocols.unavailable', 'This server does not offer the legacy protocols switch.') + : e instanceof Error + ? e.message + : String(e), + }); + }), + [t], + ); + const refresh = useCallback(() => loaded(fetchProtocolPolicy()), [loaded]); + + useEffect(() => { + const controller = new AbortController(); + void loaded(fetchProtocolPolicy(controller.signal), controller.signal); + return () => controller.abort(); + }, [loaded]); + + const turn = useCallback( + async (legacyProtocols: 'enabled' | 'disabled') => { + 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 }); + setConfirming(false); + setTyped(''); + await refresh(); + } catch (e) { + toast({ + variant: 'destructive', + title: t('legacyProtocols.failed', 'The switch did not change'), + description: e instanceof Error ? e.message : String(e), + }); + } finally { + setBusy(false); + } + }, + [refresh, t], + ); + + if (load.kind === 'loading') return ; + if (load.kind === 'error') { + return ( +
+ {load.message} +
+ ); + } + + const { policy } = load; + const off = policy.legacyProtocols === 'disabled'; + // What closes: what already did while the switch is 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; + + return ( +
+
+

{t('legacyProtocols.title', 'Legacy mail protocols')}

+

+ {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('enabled')} /> + + {stranded.length > 0 && ( +
+
+ +
+

+ {t('legacyProtocols.strandedTitle', 'Some listeners could not be reopened')} +

+

+ {t( + 'legacyProtocols.strandedBody', + 'Their ports may be taken by something else, or need a restart to bind. They are kept, and can be tried again:', + )}{' '} + {stranded.map(describeListener).join(', ')} +

+ {canUpdate && ( + + )} +
+
+
+ )} + + + + {(off || confirming) && } + + {!off && canUpdate && !confirming && ( + + )} + + {!off && confirming && ( +
{ + e.preventDefault(); + if (phraseMatches(typed)) void turn('disabled'); + }} + > + + setTyped(e.target.value)} + /> +
+ + +
+
+ )} +
+ ); +} + +function StatusCard({ + policy, + off, + busy, + canUpdate, + onTurnOn, +}: { + policy: ProtocolPolicy; + off: boolean; + busy: boolean; + canUpdate: boolean; + onTurnOn: () => void; +}) { + const { t } = useTranslation(); + const Icon = off ? ShieldCheck : ShieldOff; + return ( +
+
+ +
+

+ {off + ? t('legacyProtocols.statusOff', 'Legacy mail protocols 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.')} + {policy.changedAt !== null && ( + <> + {' '} + {t('legacyProtocols.changedAt', 'Last changed {{when}}.', { + when: new Date(policy.changedAt).toLocaleString(), + })} + + )} +

+
+
+ {off && canUpdate && ( + + )} +
+ ); +} + +function ProtocolTable({ rows, off }: { rows: ProtocolRow[]; off: boolean }) { + const { t } = useTranslation(); + const stateText = (row: ProtocolRow) => { + switch (row.state) { + case 'locked': + return 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'); + } + }; + return ( +
+ + + + + + + + + + {rows.map((row) => ( + + + + + + ))} + +
{t('legacyProtocols.colProtocol', 'Protocol')}{t('legacyProtocols.colPorts', 'Ports')} + {off ? t('legacyProtocols.colNow', 'Now') : t('legacyProtocols.colWhenOff', 'When turned off')} +
{row.label} + {row.ports.length > 0 ? row.ports.join(', ') : '—'} + + + {row.state === 'locked' && } + {stateText(row)} + +
+

+ {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.', + )} +

+
+ ); +} + +/** The statement (LP-16), at server scope, with the firewall note (LP-20). */ +function Statement({ listeners }: { listeners: PolicyListener[] }) { + const { t } = useTranslation(); + return ( +
+

+ {t('legacyProtocols.statementTitle', 'Only INBUXA webmail and JMAP apps will work.')} +

+

+ {t( + 'legacyProtocols.statementLead', + 'Legacy mail protocols (IMAP, POP3, ManageSieve and sending from mail apps) will be turned off for everyone on this server.', + )} +

+
    +
  • + {t( + 'legacyProtocols.statementApps', + 'Phone and desktop mail apps will stop receiving and sending mail. That’s iPhone and iPad Mail, the Gmail and Outlook apps, Outlook, Thunderbird and Apple Mail. People will see sign-in errors in them.', + )} +
  • +
  • + {t( + 'legacyProtocols.statementFilters', + 'Filters managed from a mail app (ManageSieve) will stop working. Filters set in INBUXA webmail keep working.', + )} +
  • +
  • + {t( + 'legacyProtocols.statementUnaffected', + 'Incoming mail is not affected. Calendars and contacts are not affected.', + )} +
  • +
  • + {t( + 'legacyProtocols.statementWebmail', + 'People keep full access through INBUXA webmail, which can be installed as an app on phones and computers.', + )} +
  • +
+

+ {listeners.length > 0 + ? t('legacyProtocols.statementPorts', 'The IMAP, POP3 and ManageSieve ports will close: {{list}}.', { + list: listeners.map(describeListener).join(', '), + }) + : t( + 'legacyProtocols.statementNoPorts', + 'No IMAP, POP3 or ManageSieve listeners are configured, so no ports will close.', + )} +

+

+ {t( + 'legacyProtocols.statementSubmission', + 'Sending from mail apps (SMTP submission) will stop working, but its ports stay open: mail apps will be told they cannot sign in. Incoming mail (SMTP) and INBUXA webmail (JMAP) are not affected and cannot be turned off here.', + )} +

+

+ {t('legacyProtocols.firewallLead', 'This does not change your firewall or port forwarding.')}{' '} + {t( + 'legacyProtocols.firewallBody', + 'INBUXA stops answering on these ports; anything that still routes them to this server — firewall rules, NAT port-forwards, a load balancer or proxy — is yours to reconcile.', + )} +

+

{t('legacyProtocols.statementUndo', 'You can turn legacy protocols back on at any time.')}

+
+ ); +} diff --git a/src/features/hardening/protocolPolicy.test.ts b/src/features/hardening/protocolPolicy.test.ts new file mode 100644 index 0000000..46ecfc0 --- /dev/null +++ b/src/features/hardening/protocolPolicy.test.ts @@ -0,0 +1,84 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, expect, it } from 'vitest'; +import { CONFIRM_PHRASE, parsePolicy, phraseMatches, protocolRows, type ProtocolPolicy } from './protocolPolicy'; + +// As inbuxa:ProtocolPolicy/get sends it: listeners keyed by the policy's own property names. +const WIRE = { + id: 'singleton', + legacyProtocols: 'enabled', + closeSubmission: false, + savedListeners: [], + changedAt: null, + changedBy: null, + lockedProtocols: ['smtp', 'lmtp', 'http'], + wouldClose: [ + { id: 'imaptls', legacyProtocols: 'imap', wouldClose: [993] }, + { id: 'imap', legacyProtocols: 'imap', wouldClose: [143] }, + { id: 'sieve', legacyProtocols: 'manageSieve', wouldClose: [4190] }, + ], +}; + +function policy(overrides: Partial = {}): ProtocolPolicy { + return { ...parsePolicy(WIRE), ...overrides }; +} + +describe('parsePolicy', () => { + it('reads listeners back into names, protocols and ports', () => { + const p = parsePolicy(WIRE); + expect(p.wouldClose[0]).toEqual({ name: 'imaptls', protocol: 'imap', ports: [993] }); + expect(p.lockedProtocols).toEqual(['smtp', 'lmtp', 'http']); + expect(p.legacyProtocols).toBe('enabled'); + }); + + it('treats anything but "disabled" as enabled, and drops malformed listeners', () => { + const p = parsePolicy({ legacyProtocols: 'maybe', wouldClose: [null, { legacyProtocols: 'imap' }] }); + expect(p.legacyProtocols).toBe('enabled'); + expect(p.wouldClose).toEqual([]); + expect(p.changedAt).toBeNull(); + }); +}); + +describe('protocolRows (LP-21)', () => { + it('lists every mail protocol, with SMTP and JMAP locked', () => { + const rows = protocolRows(policy(), policy().wouldClose); + expect(rows.map((r) => r.key)).toEqual(['imap', 'pop3', 'manageSieve', 'submission', 'smtp', 'jmap']); + expect(rows.find((r) => r.key === 'smtp')?.state).toBe('locked'); + expect(rows.find((r) => r.key === 'jmap')?.state).toBe('locked'); + }); + + it('gathers each protocol’s ports, sorted and without repeats', () => { + const rows = protocolRows(policy(), policy().wouldClose); + expect(rows.find((r) => r.key === 'imap')?.ports).toEqual([143, 993]); + expect(rows.find((r) => r.key === 'pop3')?.ports).toEqual([]); + expect(rows.find((r) => r.key === 'manageSieve')?.ports).toEqual([4190]); + }); + + it('keeps submission locked while the server locks SMTP, whatever closeSubmission says', () => { + const rows = protocolRows(policy({ closeSubmission: true }), []); + expect(rows.find((r) => r.key === 'submission')?.state).toBe('locked'); + }); + + it('follows closeSubmission once the server unlocks SMTP, with no admin change', () => { + const unlocked = policy({ lockedProtocols: ['lmtp', 'http'] }); + const listeners = [{ name: 'submissions', protocol: 'smtp', ports: [465] }]; + const closing = protocolRows({ ...unlocked, closeSubmission: true }, listeners); + expect(closing.find((r) => r.key === 'submission')).toMatchObject({ state: 'closes', ports: [465] }); + const keeping = protocolRows({ ...unlocked, closeSubmission: false }, listeners); + expect(keeping.find((r) => r.key === 'submission')?.state).toBe('refused'); + }); +}); + +describe('phraseMatches (LP-17)', () => { + it('accepts only the exact phrase', () => { + expect(phraseMatches(CONFIRM_PHRASE)).toBe(true); + expect(phraseMatches('Turn off legacy mail')).toBe(false); + expect(phraseMatches(' turn off legacy mail')).toBe(false); + expect(phraseMatches('turn off legacy')).toBe(false); + expect(phraseMatches('')).toBe(false); + }); +}); diff --git a/src/features/hardening/protocolPolicy.ts b/src/features/hardening/protocolPolicy.ts new file mode 100644 index 0000000..7f76e19 --- /dev/null +++ b/src/features/hardening/protocolPolicy.ts @@ -0,0 +1,176 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * INBUXA: `inbuxa:ProtocolPolicy`, the server-wide legacy mail protocols switch + * (legacy-protocols spec). This module is the wire and the rules; the screen + * and the banner draw from it. + */ + +import { getAccountId, jmapRequest } from '@/services/jmap/client'; +import type { JmapSetError } from '@/types/jmap'; + +export const INBUXA_CAPABILITY = 'urn:inbuxa:jmap'; +const OBJECT = 'inbuxa:ProtocolPolicy'; + +/** The phrase that turns legacy protocols off (LP-17). Turning them back on needs none. */ +export const CONFIRM_PHRASE = 'turn off legacy mail'; + +/** A listener the switch closed, or would close, by name and port (LP-16). */ +export interface PolicyListener { + name: string; + protocol: string; + ports: number[]; +} + +export interface ProtocolPolicy { + legacyProtocols: 'enabled' | 'disabled'; + closeSubmission: boolean; + /** Listeners taken away and not yet put back. Non-empty while enabled means some failed to reopen (LP-5). */ + savedListeners: PolicyListener[]; + /** Milliseconds since the epoch. */ + changedAt: number | null; + changedBy: string | null; + /** Registry protocols the switch may never close (LP-21), as the server says. */ + lockedProtocols: string[]; + /** What turning the switch off would close, whichever way it is set now (LP-16). */ + wouldClose: PolicyListener[]; +} + +/** + * The server sends each listener as an object keyed by the policy's own + * property names: `id` is the listener's name, `legacyProtocols` its protocol + * and `wouldClose` its ports. Read them back into something that says so. + */ +function parseListener(raw: unknown): PolicyListener | null { + if (!raw || typeof raw !== 'object') return null; + const r = raw as Record; + if (typeof r.id !== 'string') return null; + return { + name: r.id, + protocol: typeof r.legacyProtocols === 'string' ? r.legacyProtocols : '', + ports: Array.isArray(r.wouldClose) ? r.wouldClose.filter((p): p is number => typeof p === 'number') : [], + }; +} + +function parseListeners(raw: unknown): PolicyListener[] { + return Array.isArray(raw) ? raw.map(parseListener).filter((l): l is PolicyListener => l !== null) : []; +} + +export function parsePolicy(raw: Record): ProtocolPolicy { + return { + legacyProtocols: raw.legacyProtocols === 'disabled' ? 'disabled' : 'enabled', + closeSubmission: raw.closeSubmission === true, + savedListeners: parseListeners(raw.savedListeners), + changedAt: typeof raw.changedAt === 'number' ? raw.changedAt : null, + changedBy: typeof raw.changedBy === 'string' ? raw.changedBy : null, + lockedProtocols: Array.isArray(raw.lockedProtocols) + ? raw.lockedProtocols.filter((p): p is string => typeof p === 'string') + : [], + wouldClose: parseListeners(raw.wouldClose), + }; +} + +/** Thrown when the server has no `inbuxa:ProtocolPolicy`, so callers can stay quiet about it. */ +export class PolicyUnavailable extends Error {} + +export async function fetchProtocolPolicy(signal?: AbortSignal): Promise { + const accountId = getAccountId('x:NetworkListener'); + const responses = await jmapRequest([[`${OBJECT}/get`, { accountId, ids: null }, '0']], signal, [INBUXA_CAPABILITY]); + const [name, result] = responses[0] ?? []; + if (name !== `${OBJECT}/get`) { + const type = (result as { type?: string } | undefined)?.type; + if (type === 'unknownMethod' || type === 'unknownCapability') throw new PolicyUnavailable(type); + throw new Error((result as { description?: string } | undefined)?.description ?? type ?? 'Request failed'); + } + const list = (result as { list?: Record[] }).list ?? []; + if (!list[0]) throw new PolicyUnavailable('notFound'); + return parsePolicy(list[0]); +} + +export interface SetOutcome { + /** What the server stored differently from what was asked (LP-21), or nothing. */ + overruled: Record | null; +} + +export async function updateProtocolPolicy( + update: Partial>, +): Promise { + const accountId = getAccountId('x:NetworkListener'); + const responses = await jmapRequest( + [[`${OBJECT}/set`, { accountId, update: { singleton: update } }, '0']], + undefined, + [INBUXA_CAPABILITY], + ); + const [name, result] = responses[0] ?? []; + if (name !== `${OBJECT}/set`) { + throw new Error((result as { description?: string } | undefined)?.description ?? 'Request failed'); + } + const r = result as { + updated?: Record | null> | null; + notUpdated?: Record | null; + }; + const failed = r.notUpdated?.singleton; + if (failed) throw new Error(failed.description ?? failed.type); + const stored = r.updated?.singleton; + return { overruled: stored && Object.keys(stored).length > 0 ? stored : null }; +} + +/** How a protocol stands under the switch, for the selector (LP-21). */ +export type RowState = 'closes' | 'refused' | 'locked'; + +export interface ProtocolRow { + key: string; + label: string; + state: RowState; + /** Ports that close with the switch; empty when nothing is listening or the row doesn't close. */ + ports: number[]; +} + +function portsOf(listeners: PolicyListener[], protocol: string): number[] { + const ports = listeners.filter((l) => l.protocol === protocol).flatMap((l) => l.ports); + return [...new Set(ports)].sort((a, b) => a - b); +} + +/** + * Every mail protocol the server speaks, in one place, with what the switch + * does to each. The locked set comes from the server, so unlocking later is + * a server change and no admin release (LP-21). + * + * `listeners` is what the switch closes: `wouldClose` while it's on, or + * `savedListeners` once it's off. + */ +export function protocolRows(policy: ProtocolPolicy, listeners: PolicyListener[]): ProtocolRow[] { + const locked = new Set(policy.lockedProtocols.map((p) => p.toLowerCase())); + const legacy: ProtocolRow[] = [ + { key: 'imap', label: 'IMAP', state: 'closes', ports: portsOf(listeners, 'imap') }, + { key: 'pop3', label: 'POP3', state: 'closes', ports: portsOf(listeners, 'pop3') }, + { key: 'manageSieve', label: 'ManageSieve', state: 'closes', ports: portsOf(listeners, 'manageSieve') }, + ]; + const smtpLocked = locked.has('smtp'); + return [ + ...legacy, + { + key: 'submission', + label: 'SMTP submission', + // Locked submission keeps its ports; sign-in over them is refused instead. + state: smtpLocked ? 'locked' : policy.closeSubmission ? 'closes' : 'refused', + ports: smtpLocked ? [] : portsOf(listeners, 'smtp'), + }, + // Incoming mail and JMAP are never the switch's to close (LP-3, "Not affected, ever"). + { key: 'smtp', label: 'SMTP (incoming mail)', state: 'locked', ports: [] }, + { key: 'jmap', label: 'JMAP (INBUXA webmail)', state: 'locked', ports: [] }, + ]; +} + +/** Whether the typed confirmation matches (LP-17). Exact: no trimming, no case folding. */ +export function phraseMatches(typed: string): boolean { + return typed === CONFIRM_PHRASE; +} + +export function describeListener(l: PolicyListener): string { + return l.ports.length > 0 ? `${l.name} (${l.ports.join(', ')})` : l.name; +} diff --git a/src/lib/layout.ts b/src/lib/layout.ts index 9c8fee6..776daa2 100644 --- a/src/lib/layout.ts +++ b/src/lib/layout.ts @@ -65,6 +65,12 @@ function checkSpecialLink( return { visible: allowed, enterprise: true }; } + // INBUXA: the legacy protocols switch takes listeners away and puts them back, + // so whoever may see a listener may see it (legacy-protocols spec). + if (viewName === 'CustomComponent/LegacyProtocols') { + return { visible: canGet ? canGet('sysNetworkListener') : true, enterprise: false }; + } + if (viewName.startsWith('CustomComponent/')) { return { visible: true, enterprise: false }; } -- 2.54.0