Switch IMAP, POP3 and ManageSieve on and off one at a time #32
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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],
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user