Settings › Security › Hardening: the legacy mail protocols switch #2
@@ -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<M, P>(load: () => Promise<M>, select: (module: M) => ComponentType<P>) {
|
||||
@@ -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 <LiveTracingPage />;
|
||||
}
|
||||
// INBUXA: Settings › Security › Hardening (legacy-protocols spec).
|
||||
if (componentName === 'LegacyProtocols') {
|
||||
return <LegacyProtocolsPage />;
|
||||
}
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed p-12 text-center text-muted-foreground">
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<LegacyProtocolsBanner />
|
||||
<DynamicForm viewName={viewName} objectId="singleton" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <DynamicForm viewName={viewName} objectId="singleton" />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
<div className="space-y-6">
|
||||
<Greeting />
|
||||
<StatusLine facts={facts} />
|
||||
<LegacyProtocolsBanner />
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
{dashboards.length > 1 && (
|
||||
<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. That’s 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 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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user