diff --git a/src/features/hardening/LegacyProtocolsPage.tsx b/src/features/hardening/LegacyProtocolsPage.tsx
index 403650a..28fb015 100644
--- a/src/features/hardening/LegacyProtocolsPage.tsx
+++ b/src/features/hardening/LegacyProtocolsPage.tsx
@@ -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 (
@@ -115,12 +129,17 @@ export function LegacyProtocolsPage() {
{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.',
)}
{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.')}
{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({
{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.',
)}
diff --git a/src/features/hardening/TenantLegacyProtocols.tsx b/src/features/hardening/TenantLegacyProtocols.tsx
index 14f122c..4d99d8e 100644
--- a/src/features/hardening/TenantLegacyProtocols.tsx
+++ b/src/features/hardening/TenantLegacyProtocols.tsx
@@ -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(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(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 },
+ )}
- {canUpdate && off && (
- void turn('enabled')} className="shrink-0">
+ {canUpdate && offList.length > 0 && (
+ void turn({ legacyProtocols: 'enabled' })}
+ className="shrink-0"
+ >
{busy && }
- {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')}
)}
{canUpdate && !off && !confirming && (
setConfirming(true)} className="shrink-0">
- {t('legacyProtocols.turnOff', 'Turn off legacy protocols…')}
+ {t('legacyProtocols.turnOffAll', 'Turn off all legacy protocols…')}
)}
+
+
{!off && confirming && (
<>
{policy.recentLegacyUse && }
- void turn('disabled')} onCancel={() => setConfirming(false)} />
+ void turn({ legacyProtocols: 'disabled' })}
+ onCancel={() => setConfirming(false)}
+ />
>
)}
diff --git a/src/features/hardening/parts.tsx b/src/features/hardening/parts.tsx
index 4a9d373..b471016 100644
--- a/src/features/hardening/parts.tsx
+++ b/src/features/hardening/parts.tsx
@@ -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 (
+ <>
+ (on ? onTurnOn() : setAsking(true))}
+ />
+
+
+
+
+ {t('legacyProtocols.oneTitle', 'Turn off {{protocol}}?', { protocol: label })}
+
+
+
+
+ {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 },
+ )}
+
+ {users && users.length === 0 && (
+
+ {t('legacyProtocols.oneNobody', 'No account used {{protocol}} in the last 30 days.', {
+ protocol: label,
+ })}
+
+ )}
+ {users && users.length > 0 && (
+
+
+ {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:',
+ })}
+
+ {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,
+ })}
+
+ )}
+
{t('legacyProtocols.oneUndo', 'You can turn it back on at any time.')}
+
+
+
+
+ {t('common.cancel', 'Cancel')}
+ {
+ setAsking(false);
+ onTurnOff();
+ }}
+ >
+ {t('legacyProtocols.oneGo', 'Turn off {{protocol}}', { protocol: label })}
+
+
+
+
+ >
+ );
+}
+
+/**
+ * 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({
{busy && }
- {t('legacyProtocols.confirm', 'Turn off legacy protocols')}
+ {t('legacyProtocols.confirmAll', 'Turn off all legacy protocols')}
{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.',
})}
{' '}
- {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:')}
{entries.map((entry) => (
diff --git a/src/features/hardening/protocolPolicy.test.ts b/src/features/hardening/protocolPolicy.test.ts
index 17456fe..4cf2579 100644
--- a/src/features/hardening/protocolPolicy.test.ts
+++ b/src/features/hardening/protocolPolicy.test.ts
@@ -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([]);
+ });
+});
diff --git a/src/features/hardening/protocolPolicy.ts b/src/features/hardening/protocolPolicy.ts
index 7363c0b..8a2ca14 100644
--- a/src/features/hardening/protocolPolicy.ts
+++ b/src/features/hardening/protocolPolicy.ts
@@ -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;
+
+/** What a /set may change: the kill-all, any of the three, or both. */
+export type SwitchUpdate = Partial>;
+
+/**
+ * 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): 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): 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 = {
+export const PROTOCOL_LABELS: Record = {
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>,
+ update: SwitchUpdate & Partial>,
): Promise {
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): 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 {
+/**
+ * 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 {
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],
);