Switch IMAP, POP3 and ManageSieve on and off one at a time #32

Merged
jcoffey-dev merged 1 commits from feature/per-protocol-switches into main 2026-09-28 06:26:39 +00:00
5 changed files with 533 additions and 107 deletions

No files matched your search

+158 -49
View File
@@ -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 (
<div className="mx-auto max-w-3xl space-y-6">
@@ -115,12 +129,17 @@ export function LegacyProtocolsPage() {
<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.',
'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.',
)}
</p>
</header>
<StatusCard policy={policy} off={off} busy={busy} canUpdate={canUpdate} onTurnOn={() => turn('enabled')} />
<StatusCard
policy={policy}
busy={busy}
canUpdate={canUpdate}
onTurnOn={() => void turn({ legacyProtocols: 'enabled' })}
/>
{stranded.length > 0 && (
<div className="rounded-xl border border-destructive/40 bg-destructive/5 p-4">
@@ -138,7 +157,7 @@ export function LegacyProtocolsPage() {
{stranded.map(describeListener).join(', ')}
</p>
{canUpdate && (
<Button size="sm" variant="outline" disabled={busy} onClick={() => turn('enabled')}>
<Button size="sm" variant="outline" disabled={busy} onClick={() => void turn(retry)}>
<RotateCcw className="mr-2 h-4 w-4" />
{t('legacyProtocols.tryAgain', 'Try again')}
</Button>
@@ -148,7 +167,13 @@ export function LegacyProtocolsPage() {
</div>
)}
<ProtocolTable rows={protocolRows(policy, listeners)} off={off} />
<ProtocolTable
policy={policy}
rows={protocolRows(policy)}
busy={busy}
canUpdate={canUpdate}
onTurn={(protocol, value) => void turn({ [protocol]: value })}
/>
{!off && policy.recentLegacyUse && <ImpactPanel recent={policy.recentLegacyUse} />}
@@ -157,14 +182,14 @@ export function LegacyProtocolsPage() {
{!off && canUpdate && !confirming && (
<Button variant="destructive" onClick={() => setConfirming(true)}>
<ShieldOff className="mr-2 h-4 w-4" />
{t('legacyProtocols.turnOff', 'Turn off legacy protocols…')}
{t('legacyProtocols.turnOffAll', 'Turn off all legacy protocols…')}
</Button>
)}
{!off && confirming && (
<ConfirmTurnOff
busy={busy}
onConfirm={() => 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 (
<div
className={cn(
@@ -202,12 +235,26 @@ function StatusCard({
<p className="font-medium">
{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.')}
</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.')}
: 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({
</p>
</div>
</div>
{off && canUpdate && (
{offList.length > 0 && 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')}
{some
? t('legacyProtocols.turnAllOn', 'Turn them all back on')
: t('legacyProtocols.turnOn', 'Turn legacy protocols back on')}
</Button>
)}
</div>
);
}
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 (
<div className="overflow-hidden rounded-xl border">
<div className="overflow-x-auto 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>
<th className="px-4 py-2 font-medium">{t('legacyProtocols.colUsed', 'Used lately')}</th>
<th className="px-4 py-2 font-medium">{t('legacyProtocols.colNow', 'Now')}</th>
<th className="px-4 py-2 font-medium sr-only">{t('legacyProtocols.colSwitch', 'Switch')}</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>
))}
{rows.map((row) => {
const users = (switched(row.key) || row.key === 'submission') && recent ? usersOf(recent, row.key) : null;
return (
<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 text-muted-foreground">
{users === null
? '—'
: users.length === 0
? t('legacyProtocols.usedNobody', 'Nobody')
: t('legacyProtocols.usedCount', {
count: users.length,
defaultValue_one: '1 account',
defaultValue_other: '{{count}} accounts',
})}
</td>
<td className="px-4 py-2">
<span
className={cn(
'inline-flex items-center gap-1.5',
(row.state === 'locked' || row.off) && 'text-muted-foreground',
)}
>
{row.state === 'locked' && <Lock className="h-3.5 w-3.5" />}
{stateText(row)}
</span>
</td>
<td className="px-4 py-2 text-right">
{switched(row.key) && canUpdate && (
<ProtocolSwitch
label={row.label}
off={row.off === true}
disabled={busy}
users={users}
ports={row.ports}
scope={t('legacyProtocols.scopeServer', 'everyone on this server')}
onTurnOn={() => onTurn(row.key as SwitchedProtocol, 'enabled')}
onTurnOff={() => onTurn(row.key as SwitchedProtocol, 'disabled')}
/>
)}
</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.',
)}{' '}
{t(
'legacyProtocols.submissionNote',
'Sending from mail apps goes on while any of IMAP, POP3 or ManageSieve is on.',
)}
</p>
</div>
+110 -22
View File
@@ -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<TenantPolicy | null>(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<Switches | null>(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 },
)}
</p>
</div>
</div>
{canUpdate && off && (
<Button variant="outline" disabled={busy} onClick={() => void turn('enabled')} className="shrink-0">
{canUpdate && offList.length > 0 && (
<Button
variant="outline"
disabled={busy}
onClick={() => void turn({ legacyProtocols: 'enabled' })}
className="shrink-0"
>
{busy && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('legacyProtocols.turnOn', 'Turn legacy protocols back on')}
{off
? t('legacyProtocols.turnOn', 'Turn legacy protocols back on')
: t('legacyProtocols.turnAllOn', 'Turn them all back on')}
</Button>
)}
{canUpdate && !off && !confirming && (
<Button variant="destructive" onClick={() => setConfirming(true)} className="shrink-0">
{t('legacyProtocols.turnOff', 'Turn off legacy protocols…')}
{t('legacyProtocols.turnOffAll', 'Turn off all legacy protocols…')}
</Button>
)}
</div>
<ul className="divide-y rounded-lg border text-sm">
{SWITCHED.map((protocol) => {
const label = PROTOCOL_LABELS[protocol] ?? protocol;
const serverOff = server?.[protocol] === 'disabled';
const tenantOff = policy.switches[protocol] === 'disabled';
const users = policy.recentLegacyUse ? usersOf(policy.recentLegacyUse, protocol) : null;
return (
<li key={protocol} className="flex items-center justify-between gap-3 px-4 py-2">
<div>
<span className="font-medium">{label}</span>{' '}
<span className="text-muted-foreground">
{serverOff
? t('legacyProtocols.offServerWide', 'Off for the whole server')
: tenantOff
? t('legacyProtocols.rowOff', 'Off')
: t('legacyProtocols.rowOn', 'On')}
{users !== null &&
users.length > 0 &&
` · ${t('legacyProtocols.usedCount', {
count: users.length,
defaultValue_one: '1 account',
defaultValue_other: '{{count}} accounts',
})}`}
</span>
</div>
{canUpdate && (
<ProtocolSwitch
label={label}
off={tenantOff || serverOff}
disabled={busy || serverOff}
users={users}
ports={null}
scope={scope}
onTurnOn={() => void turn({ [protocol]: 'enabled' })}
onTurnOff={() => void turn({ [protocol]: 'disabled' })}
/>
)}
</li>
);
})}
</ul>
{!off && confirming && (
<>
{policy.recentLegacyUse && <ImpactPanel recent={policy.recentLegacyUse} />}
<Statement scope={{ kind: 'tenant', organization: name }} />
<ConfirmTurnOff busy={busy} onConfirm={() => void turn('disabled')} onCancel={() => setConfirming(false)} />
<ConfirmTurnOff
busy={busy}
onConfirm={() => void turn({ legacyProtocols: 'disabled' })}
onCancel={() => setConfirming(false)}
/>
</>
)}
</section>
+145 -7
View File
@@ -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 (
<>
<Switch
checked={!off}
disabled={disabled}
aria-label={t('legacyProtocols.switchLabel', '{{protocol}} on', { protocol: label })}
onCheckedChange={(on) => (on ? onTurnOn() : setAsking(true))}
/>
<AlertDialog open={asking} onOpenChange={setAsking}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t('legacyProtocols.oneTitle', 'Turn off {{protocol}}?', { protocol: label })}
</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-3 text-sm">
<p>
{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 },
)}
</p>
{users && users.length === 0 && (
<p>
{t('legacyProtocols.oneNobody', 'No account used {{protocol}} in the last 30 days.', {
protocol: label,
})}
</p>
)}
{users && users.length > 0 && (
<div>
<p className="font-medium text-foreground">
{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:',
})}
</p>
<ul className="mt-1 space-y-0.5">
{users.slice(0, NAMES_SHOWN).map((user) => (
<li key={user.name}>
{user.name}{' '}
<span className="text-muted-foreground">· {ago(user.lastUsedAt, now, i18n.language)}</span>
</li>
))}
</ul>
{users.length > NAMES_SHOWN && (
<p className="text-muted-foreground">
{t('legacyProtocols.oneMore', 'and {{count}} more', { count: users.length - NAMES_SHOWN })}
</p>
)}
</div>
)}
{ports !== null && (
<p>
{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,
})}
</p>
)}
<p>{t('legacyProtocols.oneUndo', 'You can turn it back on at any time.')}</p>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('common.cancel', 'Cancel')}</AlertDialogCancel>
<Button
variant="destructive"
onClick={() => {
setAsking(false);
onTurnOff();
}}
>
{t('legacyProtocols.oneGo', 'Turn off {{protocol}}', { protocol: label })}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
/**
* 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({
<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')}
{t('legacyProtocols.confirmAll', 'Turn off all legacy protocols')}
</Button>
<Button type="button" variant="ghost" disabled={busy} onClick={onCancel}>
{t('common.cancel', 'Cancel')}
@@ -99,7 +237,7 @@ export function ImpactPanel({ recent }: { recent: RecentUse[] }) {
defaultValue_other: '{{count}} accounts used a legacy mail app in the last 30 days.',
})}
</strong>{' '}
{t('legacyProtocols.impactLead', 'Their mail apps will stop working the moment you turn this on:')}
{t('legacyProtocols.impactLeadAll', 'Their mail apps stop working when what they use is turned off:')}
</p>
<ul className="max-h-72 space-y-1 overflow-y-auto">
{entries.map((entry) => (
+47 -6
View File
@@ -9,6 +9,8 @@ import {
ago,
CONFIRM_PHRASE,
impactEntries,
offProtocols,
usersOf,
parsePolicy,
parseTenantPolicy,
phraseMatches,
@@ -54,30 +56,30 @@ describe('parsePolicy', () => {
describe('protocolRows (LP-21)', () => {
it('lists every mail protocol, with SMTP and JMAP locked', () => {
const rows = protocolRows(policy(), policy().wouldClose);
const rows = protocolRows(policy());
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);
const rows = protocolRows(policy());
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 }), []);
const rows = protocolRows(policy({ closeSubmission: true, wouldClose: [] }));
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);
const unlocked = policy({ lockedProtocols: ['lmtp', 'http'], wouldClose: listeners });
const closing = protocolRows({ ...unlocked, closeSubmission: true });
expect(closing.find((r) => r.key === 'submission')).toMatchObject({ state: 'closes', ports: [465] });
const keeping = protocolRows({ ...unlocked, closeSubmission: false }, listeners);
const keeping = protocolRows({ ...unlocked, closeSubmission: false });
expect(keeping.find((r) => r.key === 'submission')?.state).toBe('refused');
});
});
@@ -136,3 +138,42 @@ describe("a tenant's switch", () => {
expect(parseTenantPolicy({ id: 'b' })).toMatchObject({ legacyProtocols: 'enabled', recentLegacyUse: null });
});
});
describe('one switch per protocol', () => {
it('reads each switch, and an older server’s one switch as all three', () => {
const now = parsePolicy({ ...WIRE, imap: 'enabled', pop3: 'disabled', manageSieve: 'enabled' });
expect(now.switches).toEqual({ imap: 'enabled', pop3: 'disabled', manageSieve: 'enabled' });
expect(offProtocols(now.switches)).toEqual(['pop3']);
const older = parsePolicy({ ...WIRE, legacyProtocols: 'disabled' });
expect(offProtocols(older.switches)).toEqual(['imap', 'pop3', 'manageSieve']);
expect(parseTenantPolicy({ id: 't', legacyProtocols: 'disabled' }).switches.pop3).toBe('disabled');
expect(parseTenantPolicy({ id: 't', manageSieve: 'disabled' }).switches).toEqual({
imap: 'enabled',
pop3: 'enabled',
manageSieve: 'disabled',
});
});
it('shows the ports a protocol closed while it is off, and would close while it is on', () => {
const p = policy({
switches: { imap: 'disabled', pop3: 'enabled', manageSieve: 'enabled' },
savedListeners: [{ name: 'imaptls', protocol: 'imap', ports: [993] }],
wouldClose: [{ name: 'sieve', protocol: 'manageSieve', ports: [4190] }],
});
const rows = protocolRows(p);
expect(rows.find((r) => r.key === 'imap')).toMatchObject({ off: true, ports: [993] });
expect(rows.find((r) => r.key === 'manageSieve')).toMatchObject({ off: false, ports: [4190] });
expect(rows.find((r) => r.key === 'pop3')).toMatchObject({ off: false, ports: [] });
});
it('lists one protocol’s recent users', () => {
const recent = [
{ accountId: 'a', name: 'ada', protocol: 'pop3', lastUsedAt: 2 },
{ accountId: 'b', name: 'bo', protocol: 'imap', lastUsedAt: 3 },
{ accountId: 'a', name: 'ada', protocol: 'imap', lastUsedAt: 1 },
];
expect(usersOf(recent, 'pop3').map((u) => u.name)).toEqual(['ada']);
expect(usersOf(recent, 'imap').map((u) => u.name)).toEqual(['bo', 'ada']);
expect(usersOf(recent, 'manageSieve')).toEqual([]);
});
});
+73 -23
View File
@@ -5,9 +5,11 @@
*/
/**
* 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.
* INBUXA: `inbuxa:ProtocolPolicy`, the server-wide legacy mail protocols
* switches (legacy-protocols spec). IMAP, POP3 and ManageSieve each have their
* own; `legacyProtocols` is the kill-all, which sets all three and reads
* `disabled` when all three are off. This module is the wire and the rules;
* the screen and the banner draw from it.
*/
import { getAccountId, jmapRequest } from '@/services/jmap/client';
@@ -19,6 +21,31 @@ 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';
export type Switch = 'enabled' | 'disabled';
/** The protocols with a switch of their own, as the server names them. */
export const SWITCHED = ['imap', 'pop3', 'manageSieve'] as const;
export type SwitchedProtocol = (typeof SWITCHED)[number];
export type Switches = Record<SwitchedProtocol, Switch>;
/** What a /set may change: the kill-all, any of the three, or both. */
export type SwitchUpdate = Partial<Record<'legacyProtocols' | SwitchedProtocol, Switch>>;
/**
* Each protocol's switch. A server from before the per-protocol switches
* sends only `legacyProtocols`, which then stands for all three.
*/
function parseSwitches(raw: Record<string, unknown>): Switches {
const all: Switch = raw.legacyProtocols === 'disabled' ? 'disabled' : 'enabled';
const one = (value: unknown): Switch => (value === 'disabled' ? 'disabled' : value === 'enabled' ? 'enabled' : all);
return { imap: one(raw.imap), pop3: one(raw.pop3), manageSieve: one(raw.manageSieve) };
}
/** The protocols switched off, in the order the screen lists them. */
export function offProtocols(switches: Switches): SwitchedProtocol[] {
return SWITCHED.filter((p) => switches[p] === 'disabled');
}
/** A listener the switch closed, or would close, by name and port (LP-16). */
export interface PolicyListener {
name: string;
@@ -27,7 +54,9 @@ export interface PolicyListener {
}
export interface ProtocolPolicy {
legacyProtocols: 'enabled' | 'disabled';
/** The kill-all: `disabled` only when all three protocols are off. */
legacyProtocols: Switch;
switches: Switches;
closeSubmission: boolean;
/** Listeners taken away and not yet put back. Non-empty while enabled means some failed to reopen (LP-5). */
savedListeners: PolicyListener[];
@@ -84,6 +113,7 @@ function parseListeners(raw: unknown): PolicyListener[] {
export function parsePolicy(raw: Record<string, unknown>): ProtocolPolicy {
return {
legacyProtocols: raw.legacyProtocols === 'disabled' ? 'disabled' : 'enabled',
switches: parseSwitches(raw),
closeSubmission: raw.closeSubmission === true,
savedListeners: parseListeners(raw.savedListeners),
changedAt: typeof raw.changedAt === 'number' ? raw.changedAt : null,
@@ -112,7 +142,7 @@ function parseRecent(raw: unknown[]): RecentUse[] {
});
}
const PROTOCOL_LABELS: Record<string, string> = {
export const PROTOCOL_LABELS: Record<string, string> = {
imap: 'IMAP',
pop3: 'POP3',
manageSieve: 'ManageSieve',
@@ -139,6 +169,11 @@ export function impactEntries(recent: RecentUse[]): ImpactEntry[] {
.sort((a, b) => b.lastUsedAt - a.lastUsedAt || a.name.localeCompare(b.name));
}
/** The accounts that used one protocol lately (or `submission`), for its row and its switch. */
export function usersOf(recent: RecentUse[], protocol: SwitchedProtocol | 'submission'): ImpactEntry[] {
return impactEntries(recent.filter((use) => use.protocol === protocol));
}
/** "2 days ago", "3 hours ago", "just now", in the reader's language. */
export function ago(at: number, now: number, locale?: string): string {
const seconds = Math.round((at - now) / 1000);
@@ -179,7 +214,7 @@ export interface SetOutcome {
}
export async function updateProtocolPolicy(
update: Partial<Pick<ProtocolPolicy, 'legacyProtocols' | 'closeSubmission'>>,
update: SwitchUpdate & Partial<Pick<ProtocolPolicy, 'closeSubmission'>>,
): Promise<SetOutcome> {
const accountId = getAccountId('x:NetworkListener');
const responses = await jmapRequest(
@@ -210,6 +245,8 @@ export interface ProtocolRow {
state: RowState;
/** Ports that close with the switch; empty when nothing is listening or the row doesn't close. */
ports: number[];
/** For a protocol with its own switch: whether it is off now. */
off?: boolean;
}
function portsOf(listeners: PolicyListener[], protocol: string): number[] {
@@ -218,29 +255,36 @@ function portsOf(listeners: PolicyListener[], protocol: string): number[] {
}
/**
* 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).
* Every mail protocol the server speaks, in one place, with what the switches
* do 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.
* A protocol that is off shows the ports it closed (`savedListeners`); one
* that is on, the ports turning it off would close (`wouldClose`).
*/
export function protocolRows(policy: ProtocolPolicy, listeners: PolicyListener[]): ProtocolRow[] {
export function protocolRows(policy: ProtocolPolicy): 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 legacy: ProtocolRow[] = SWITCHED.map((key) => {
const off = policy.switches[key] === 'disabled';
return {
key,
label: PROTOCOL_LABELS[key] ?? key,
state: 'closes',
ports: portsOf(off ? policy.savedListeners : policy.wouldClose, key),
off,
};
});
const smtpLocked = locked.has('smtp');
const allOff = policy.legacyProtocols === 'disabled';
return [
...legacy,
{
key: 'submission',
label: 'SMTP submission',
// Locked submission keeps its ports; sign-in over them is refused instead.
// Locked submission keeps its ports; sign-in over them is refused
// instead, and only with all three protocols off.
state: smtpLocked ? 'locked' : policy.closeSubmission ? 'closes' : 'refused',
ports: smtpLocked ? [] : portsOf(listeners, 'smtp'),
ports: smtpLocked ? [] : portsOf(allOff ? policy.savedListeners : policy.wouldClose, '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: [] },
@@ -264,7 +308,9 @@ const TENANT_OBJECT = 'inbuxa:TenantProtocolPolicy';
export interface TenantPolicy {
/** The tenant's id, which is also the policy's. */
id: string;
legacyProtocols: 'enabled' | 'disabled';
/** The kill-all: `disabled` only when all three protocols are off. */
legacyProtocols: Switch;
switches: Switches;
/** Milliseconds since the epoch. */
changedAt: number | null;
/** The tenant's own people who used a legacy mail app lately (LP-15), or null from an older server. */
@@ -275,6 +321,7 @@ export function parseTenantPolicy(raw: Record<string, unknown>): TenantPolicy {
return {
id: typeof raw.id === 'string' ? raw.id : '',
legacyProtocols: raw.legacyProtocols === 'disabled' ? 'disabled' : 'enabled',
switches: parseSwitches(raw),
changedAt: typeof raw.changedAt === 'number' ? raw.changedAt : null,
recentLegacyUse: Array.isArray(raw.recentLegacyUse) ? parseRecent(raw.recentLegacyUse) : null,
};
@@ -303,11 +350,14 @@ export async function fetchTenantPolicy(tenantId: string | null, signal?: AbortS
return parseTenantPolicy(list[0]);
}
/** Turns a tenant's switch. The server refuses turning it on while its own is off (LP-9). */
export async function updateTenantPolicy(tenantId: string, legacyProtocols: 'enabled' | 'disabled'): Promise<void> {
/**
* Turns a tenant's switches. The server refuses turning a protocol on while
* its own switch for it is off (LP-9), and says which.
*/
export async function updateTenantPolicy(tenantId: string, update: SwitchUpdate): Promise<void> {
const accountId = getAccountId('x:Domain');
const responses = await jmapRequest(
[[`${TENANT_OBJECT}/set`, { accountId, update: { [tenantId]: { legacyProtocols } } }, '0']],
[[`${TENANT_OBJECT}/set`, { accountId, update: { [tenantId]: update } }, '0']],
undefined,
[INBUXA_CAPABILITY],
);