Settings › Security › Hardening: the legacy mail protocols switch #2

Merged
jcoffey-dev merged 1 commits from feat/legacy-protocols-screen into main 2026-09-21 16:30:19 +00:00
7 changed files with 723 additions and 0 deletions
Showing only changes of commit b6b54d2ac0 - Show all commits
+18
View File
@@ -16,6 +16,7 @@ import { DynamicList } from '@/components/lists/DynamicList';
import { DynamicForm } from '@/components/forms/DynamicForm'; import { DynamicForm } from '@/components/forms/DynamicForm';
import { DynamicViewPage } from '@/components/views/DynamicViewPage'; import { DynamicViewPage } from '@/components/views/DynamicViewPage';
import { LoadingFallback } from '@/components/common/LoadingFallback'; import { LoadingFallback } from '@/components/common/LoadingFallback';
import { LegacyProtocolsBanner } from '@/features/hardening/LegacyProtocolsBanner';
import type { Schema } from '@/types/schema'; import type { Schema } from '@/types/schema';
function lazyFeature<M, P>(load: () => Promise<M>, select: (module: M) => ComponentType<P>) { function lazyFeature<M, P>(load: () => Promise<M>, select: (module: M) => ComponentType<P>) {
@@ -46,6 +47,10 @@ const ActionPage = lazyFeature(
() => import('@/features/actions/ActionPage'), () => import('@/features/actions/ActionPage'),
(m) => m.ActionPage, (m) => m.ActionPage,
); );
const LegacyProtocolsPage = lazyFeature(
() => import('@/features/hardening/LegacyProtocolsPage'),
(m) => m.LegacyProtocolsPage,
);
interface MainContentProps { interface MainContentProps {
viewName?: string; viewName?: string;
@@ -99,6 +104,10 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti
if (componentName === 'LiveTracing') { if (componentName === 'LiveTracing') {
return <LiveTracingPage />; return <LiveTracingPage />;
} }
// INBUXA: Settings Security Hardening (legacy-protocols spec).
if (componentName === 'LegacyProtocols') {
return <LegacyProtocolsPage />;
}
return ( return (
<div className="rounded-lg border border-dashed p-12 text-center text-muted-foreground"> <div className="rounded-lg border border-dashed p-12 text-center text-muted-foreground">
Unknown component: {componentName} Unknown component: {componentName}
@@ -120,6 +129,15 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti
} }
if (resolved.objectType.type === 'singleton') { if (resolved.objectType.type === 'singleton') {
// INBUXA: the Security settings carry the legacy protocols banner (LP-18).
if (resolved.objectName === 'x:Security') {
return (
<div className="space-y-4">
<LegacyProtocolsBanner />
<DynamicForm viewName={viewName} objectId="singleton" />
</div>
);
}
return <DynamicForm viewName={viewName} objectId="singleton" />; return <DynamicForm viewName={viewName} objectId="singleton" />;
} }
@@ -15,6 +15,7 @@ import { AlertCircle } from 'lucide-react';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useSchemaStore } from '@/stores/schemaStore'; import { useSchemaStore } from '@/stores/schemaStore';
import type { Dashboard } from '../types/schema'; import type { Dashboard } from '../types/schema';
import { LegacyProtocolsBanner } from '@/features/hardening/LegacyProtocolsBanner';
import { useDashboardStore } from '../stores/dashboardStore'; import { useDashboardStore } from '../stores/dashboardStore';
import { useLiveMetricsStore } from '../stores/liveMetricsStore'; import { useLiveMetricsStore } from '../stores/liveMetricsStore';
import { useHistoryMetricsStore } from '../stores/historyMetricsStore'; import { useHistoryMetricsStore } from '../stores/historyMetricsStore';
@@ -123,6 +124,7 @@ export function DashboardView({ dashboardId, section }: DashboardViewProps) {
<div className="space-y-6"> <div className="space-y-6">
<Greeting /> <Greeting />
<StatusLine facts={facts} /> <StatusLine facts={facts} />
<LegacyProtocolsBanner />
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
{dashboards.length > 1 && ( {dashboards.length > 1 && (
<Tabs value={dashboardId} onValueChange={(id) => navigate(`/${section}/Dashboard/${id}`)}> <Tabs value={dashboardId} onValueChange={(id) => navigate(`/${section}/Dashboard/${id}`)}>
@@ -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 (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 rounded-xl border border-emerald-500/30 bg-emerald-500/5 px-4 py-3 text-sm">
<ShieldCheck className="h-4 w-4 shrink-0 text-emerald-600" />
<span>
{t('legacyProtocols.bannerLead', 'Legacy mail protocols are')}{' '}
<strong>{t('legacyProtocols.bannerOff', 'off')}</strong>{' '}
{t('legacyProtocols.bannerTail', 'on this server. Only INBUXA webmail and JMAP apps can sign in.')}
</span>
<Link to={`/Settings/${LEGACY_PROTOCOLS_VIEW}`} className="font-medium text-primary hover:underline">
{t('legacyProtocols.review', 'Review')}
</Link>
</div>
);
}
@@ -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<Load>({ kind: 'loading' });
const [confirming, setConfirming] = useState(false);
const [typed, setTyped] = useState('');
const [busy, setBusy] = useState(false);
const loaded = useCallback(
(fetching: Promise<ProtocolPolicy>, 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 <LoadingFallback />;
if (load.kind === 'error') {
return (
<div className="mx-auto max-w-3xl rounded-lg border border-dashed p-12 text-center text-muted-foreground">
{load.message}
</div>
);
}
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 (
<div className="mx-auto max-w-3xl space-y-6">
<header className="space-y-1">
<h1 className="text-2xl font-semibold">{t('legacyProtocols.title', 'Legacy mail protocols')}</h1>
<p className="text-muted-foreground">
{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.',
)}
</p>
</header>
<StatusCard policy={policy} off={off} busy={busy} canUpdate={canUpdate} onTurnOn={() => turn('enabled')} />
{stranded.length > 0 && (
<div className="rounded-xl border border-destructive/40 bg-destructive/5 p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-destructive" />
<div className="space-y-2">
<p className="font-medium">
{t('legacyProtocols.strandedTitle', 'Some listeners could not be reopened')}
</p>
<p className="text-sm text-muted-foreground">
{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(', ')}
</p>
{canUpdate && (
<Button size="sm" variant="outline" disabled={busy} onClick={() => turn('enabled')}>
<RotateCcw className="mr-2 h-4 w-4" />
{t('legacyProtocols.tryAgain', 'Try again')}
</Button>
)}
</div>
</div>
</div>
)}
<ProtocolTable rows={protocolRows(policy, listeners)} off={off} />
{(off || confirming) && <Statement listeners={listeners} />}
{!off && canUpdate && !confirming && (
<Button variant="destructive" onClick={() => setConfirming(true)}>
<ShieldOff className="mr-2 h-4 w-4" />
{t('legacyProtocols.turnOff', 'Turn off legacy protocols…')}
</Button>
)}
{!off && confirming && (
<form
className="space-y-3 rounded-xl border p-4"
onSubmit={(e) => {
e.preventDefault();
if (phraseMatches(typed)) void turn('disabled');
}}
>
<Label htmlFor="legacy-confirm">
{t('legacyProtocols.typeToConfirm', 'To confirm, type')}{' '}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-sm">{CONFIRM_PHRASE}</code>
</Label>
<Input
id="legacy-confirm"
autoComplete="off"
spellCheck={false}
value={typed}
onChange={(e) => setTyped(e.target.value)}
/>
<div className="flex gap-2">
<Button type="submit" variant="destructive" disabled={busy || !phraseMatches(typed)}>
{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('legacyProtocols.confirm', 'Turn off legacy protocols')}
</Button>
<Button
type="button"
variant="ghost"
disabled={busy}
onClick={() => {
setConfirming(false);
setTyped('');
}}
>
{t('common.cancel', 'Cancel')}
</Button>
</div>
</form>
)}
</div>
);
}
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 (
<div
className={cn(
'flex flex-col gap-4 rounded-xl border p-4 sm:flex-row sm:items-center sm:justify-between',
off ? 'border-emerald-500/30 bg-emerald-500/5' : 'bg-card',
)}
>
<div className="flex items-start gap-3">
<Icon className={cn('mt-0.5 h-5 w-5 shrink-0', off ? 'text-emerald-600' : 'text-muted-foreground')} />
<div>
<p className="font-medium">
{off
? t('legacyProtocols.statusOff', 'Legacy mail protocols are off on this server.')
: t('legacyProtocols.statusOn', 'Legacy mail protocols are on.')}
</p>
<p className="text-sm text-muted-foreground">
{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(),
})}
</>
)}
</p>
</div>
</div>
{off && canUpdate && (
<Button variant="outline" disabled={busy} onClick={onTurnOn} className="shrink-0">
{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('legacyProtocols.turnOn', 'Turn legacy protocols back on')}
</Button>
)}
</div>
);
}
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 (
<div className="overflow-hidden rounded-xl border">
<table className="w-full text-sm">
<thead className="bg-muted/50 text-left text-muted-foreground">
<tr>
<th className="px-4 py-2 font-medium">{t('legacyProtocols.colProtocol', 'Protocol')}</th>
<th className="px-4 py-2 font-medium">{t('legacyProtocols.colPorts', 'Ports')}</th>
<th className="px-4 py-2 font-medium">
{off ? t('legacyProtocols.colNow', 'Now') : t('legacyProtocols.colWhenOff', 'When turned off')}
</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.key} className="border-t">
<td className="px-4 py-2 font-medium">{row.label}</td>
<td className="px-4 py-2 tabular-nums text-muted-foreground">
{row.ports.length > 0 ? row.ports.join(', ') : '—'}
</td>
<td className="px-4 py-2">
<span
className={cn('inline-flex items-center gap-1.5', row.state === 'locked' && 'text-muted-foreground')}
>
{row.state === 'locked' && <Lock className="h-3.5 w-3.5" />}
{stateText(row)}
</span>
</td>
</tr>
))}
</tbody>
</table>
<p className="border-t bg-muted/30 px-4 py-2 text-xs text-muted-foreground">
{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.',
)}
</p>
</div>
);
}
/** The statement (LP-16), at server scope, with the firewall note (LP-20). */
function Statement({ listeners }: { listeners: PolicyListener[] }) {
const { t } = useTranslation();
return (
<section className="space-y-3 rounded-xl border border-amber-500/40 bg-amber-500/5 p-5 text-sm leading-relaxed">
<p className="text-base font-semibold">
{t('legacyProtocols.statementTitle', 'Only INBUXA webmail and JMAP apps will work.')}
</p>
<p>
{t(
'legacyProtocols.statementLead',
'Legacy mail protocols (IMAP, POP3, ManageSieve and sending from mail apps) will be turned off for everyone on this server.',
)}
</p>
<ul className="list-disc space-y-1 pl-5">
<li>
{t(
'legacyProtocols.statementApps',
'Phone and desktop mail apps will stop receiving and sending mail. Thats iPhone and iPad Mail, the Gmail and Outlook apps, Outlook, Thunderbird and Apple Mail. People will see sign-in errors in them.',
)}
</li>
<li>
{t(
'legacyProtocols.statementFilters',
'Filters managed from a mail app (ManageSieve) will stop working. Filters set in INBUXA webmail keep working.',
)}
</li>
<li>
{t(
'legacyProtocols.statementUnaffected',
'Incoming mail is not affected. Calendars and contacts are not affected.',
)}
</li>
<li>
{t(
'legacyProtocols.statementWebmail',
'People keep full access through INBUXA webmail, which can be installed as an app on phones and computers.',
)}
</li>
</ul>
<p>
{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.',
)}
</p>
<p>
{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.',
)}
</p>
<p>
<strong>{t('legacyProtocols.firewallLead', 'This does not change your firewall or port forwarding.')}</strong>{' '}
{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.',
)}
</p>
<p>{t('legacyProtocols.statementUndo', 'You can turn legacy protocols back on at any time.')}</p>
</section>
);
}
@@ -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> = {}): 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 protocols 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);
});
});
+176
View File
@@ -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<string, unknown>;
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<string, unknown>): 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<ProtocolPolicy> {
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<string, unknown>[] }).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<string, unknown> | null;
}
export async function updateProtocolPolicy(
update: Partial<Pick<ProtocolPolicy, 'legacyProtocols' | 'closeSubmission'>>,
): Promise<SetOutcome> {
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<string, Record<string, unknown> | null> | null;
notUpdated?: Record<string, JmapSetError> | 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;
}
+6
View File
@@ -65,6 +65,12 @@ function checkSpecialLink(
return { visible: allowed, enterprise: true }; 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/')) { if (viewName.startsWith('CustomComponent/')) {
return { visible: true, enterprise: false }; return { visible: true, enterprise: false };
} }