/* * SPDX-FileCopyrightText: 2026 Coffey Labs * * SPDX-License-Identifier: AGPL-3.0-only */ /** * INBUXA: Settings › Security › Hardening, the server-wide legacy mail * protocols switch (legacy-protocols spec, LP-16, LP-17, LP-20, LP-21). * * Nobody should turn this on by accident or without understanding it, so the * statement is shown in full before the switch moves, and turning it on takes * a typed phrase. Turning it back on is one click: undoing a restriction must * never be the hard part. */ import { useCallback, useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { AlertTriangle, Loader2, Lock, RotateCcw, ShieldCheck, ShieldOff } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { LoadingFallback } from '@/components/common/LoadingFallback'; import { useAccountStore } from '@/stores/accountStore'; import { toast } from '@/hooks/use-toast'; import { cn } from '@/lib/utils'; import { ago, CONFIRM_PHRASE, describeListener, impactEntries, fetchProtocolPolicy, phraseMatches, PolicyUnavailable, protocolRows, updateProtocolPolicy, type PolicyListener, type ProtocolPolicy, type ProtocolRow, type RecentUse, } from './protocolPolicy'; type Load = { kind: 'loading' } | { kind: 'ready'; policy: ProtocolPolicy } | { kind: 'error'; message: string }; export function LegacyProtocolsPage() { const { t } = useTranslation(); const canUpdate = useAccountStore((s) => s.hasObjectPermission('sysNetworkListener', 'Update')); const [load, setLoad] = useState({ kind: 'loading' }); const [confirming, setConfirming] = useState(false); const [typed, setTyped] = useState(''); const [busy, setBusy] = useState(false); const loaded = useCallback( (fetching: Promise, signal?: AbortSignal) => fetching .then((policy) => { if (!signal?.aborted) setLoad({ kind: 'ready', policy }); }) .catch((e: unknown) => { if (signal?.aborted) return; setLoad({ kind: 'error', message: e instanceof PolicyUnavailable ? t('legacyProtocols.unavailable', 'This server does not offer the legacy protocols switch.') : e instanceof Error ? e.message : String(e), }); }), [t], ); const refresh = useCallback(() => loaded(fetchProtocolPolicy()), [loaded]); useEffect(() => { const controller = new AbortController(); void loaded(fetchProtocolPolicy(controller.signal), controller.signal); return () => controller.abort(); }, [loaded]); const turn = useCallback( async (legacyProtocols: 'enabled' | 'disabled') => { setBusy(true); try { // Only legacyProtocols is sent. The server may still report closeSubmission // overruled by the SMTP lock (LP-21), which the selector already shows. await updateProtocolPolicy({ legacyProtocols }); setConfirming(false); setTyped(''); await refresh(); } catch (e) { toast({ variant: 'destructive', title: t('legacyProtocols.failed', 'The switch did not change'), description: e instanceof Error ? e.message : String(e), }); } finally { setBusy(false); } }, [refresh, t], ); if (load.kind === 'loading') return ; if (load.kind === 'error') { return (
{load.message}
); } const { policy } = load; const off = policy.legacyProtocols === 'disabled'; // What closes: what already did while the switch is off, what would otherwise. const listeners = off ? policy.savedListeners : policy.wouldClose; // Enabled with listeners still saved: some could not be put back (LP-5). const stranded = off ? [] : policy.savedListeners; return (

{t('legacyProtocols.title', 'Legacy mail protocols')}

{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('enabled')} /> {stranded.length > 0 && (

{t('legacyProtocols.strandedTitle', 'Some listeners could not be reopened')}

{t( 'legacyProtocols.strandedBody', 'Their ports may be taken by something else, or need a restart to bind. They are kept, and can be tried again:', )}{' '} {stranded.map(describeListener).join(', ')}

{canUpdate && ( )}
)} {!off && policy.recentLegacyUse && } {(off || confirming) && } {!off && canUpdate && !confirming && ( )} {!off && confirming && (
{ e.preventDefault(); if (phraseMatches(typed)) void turn('disabled'); }} > setTyped(e.target.value)} />
)}
); } function StatusCard({ policy, off, busy, canUpdate, onTurnOn, }: { policy: ProtocolPolicy; off: boolean; busy: boolean; canUpdate: boolean; onTurnOn: () => void; }) { const { t } = useTranslation(); const Icon = off ? ShieldCheck : ShieldOff; return (

{off ? t('legacyProtocols.statusOff', 'Legacy mail protocols 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.')} {policy.changedAt !== null && ( <> {' '} {t('legacyProtocols.changedAt', 'Last changed {{when}}.', { when: new Date(policy.changedAt).toLocaleString(), })} )}

{off && canUpdate && ( )}
); } function ProtocolTable({ rows, off }: { rows: ProtocolRow[]; off: boolean }) { const { t } = useTranslation(); const stateText = (row: ProtocolRow) => { switch (row.state) { case 'locked': return t('legacyProtocols.rowLocked', 'Locked open'); case 'refused': return t('legacyProtocols.rowRefused', 'Port open, sign-in refused'); case 'closes': if (row.ports.length === 0) return t('legacyProtocols.rowNoListener', 'No listener'); return off ? t('legacyProtocols.rowClosed', 'Closed') : t('legacyProtocols.rowWouldClose', 'Closes'); } }; return (
{rows.map((row) => ( ))}
{t('legacyProtocols.colProtocol', 'Protocol')} {t('legacyProtocols.colPorts', 'Ports')} {off ? t('legacyProtocols.colNow', 'Now') : t('legacyProtocols.colWhenOff', 'When turned off')}
{row.label} {row.ports.length > 0 ? row.ports.join(', ') : '—'} {row.state === 'locked' && } {stateText(row)}

{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.', )}

); } /** * The impact panel (LP-15): who would notice, shown before anything can * change. With nobody, it says so in one line. */ function ImpactPanel({ recent }: { recent: RecentUse[] }) { const { t, i18n } = useTranslation(); const entries = impactEntries(recent); // Read once, when the panel appears: "2 days ago" needn't tick. const [now] = useState(() => Date.now()); if (entries.length === 0) { return (

{t('legacyProtocols.impactNone', 'No account used a legacy mail app in the last 30 days.')}

); } return (

{t('legacyProtocols.impactCount', { count: entries.length, defaultValue_one: '1 account used a legacy mail app in the last 30 days.', 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:')}

    {entries.map((entry) => (
  • {entry.name} {entry.protocols.join(', ')} · {ago(entry.lastUsedAt, now, i18n.language)}
  • ))}
); } /** The statement (LP-16), at server scope, with the firewall note (LP-20). */ function Statement({ listeners }: { listeners: PolicyListener[] }) { const { t } = useTranslation(); return (

{t('legacyProtocols.statementTitle', 'Only INBUXA webmail and JMAP apps will work.')}

{t( 'legacyProtocols.statementLead', 'Legacy mail protocols (IMAP, POP3, ManageSieve and sending from mail apps) will be turned off for everyone on this server.', )}

  • {t( 'legacyProtocols.statementApps', 'Phone and desktop mail apps will stop receiving and sending mail. That’s iPhone and iPad Mail, the Gmail and Outlook apps, Outlook, Thunderbird and Apple Mail. People will see sign-in errors in them.', )}
  • {t( 'legacyProtocols.statementFilters', 'Filters managed from a mail app (ManageSieve) will stop working. Filters set in INBUXA webmail keep working.', )}
  • {t( 'legacyProtocols.statementUnaffected', 'Incoming mail is not affected. Calendars and contacts are not affected.', )}
  • {t( 'legacyProtocols.statementWebmail', 'People keep full access through INBUXA webmail, which can be installed as an app on phones and computers.', )}

{listeners.length > 0 ? t('legacyProtocols.statementPorts', 'The IMAP, POP3 and ManageSieve ports will close: {{list}}.', { list: listeners.map(describeListener).join(', '), }) : t( 'legacyProtocols.statementNoPorts', 'No IMAP, POP3 or ManageSieve listeners are configured, so no ports will close.', )}

{t( 'legacyProtocols.statementSubmission', 'Sending from mail apps (SMTP submission) will stop working, but its ports stay open: mail apps will be told they cannot sign in. Incoming mail (SMTP) and INBUXA webmail (JMAP) are not affected and cannot be turned off here.', )}

{t('legacyProtocols.firewallLead', 'This does not change your firewall or port forwarding.')}{' '} {t( 'legacyProtocols.firewallBody', 'INBUXA stops answering on these ports; anything that still routes them to this server — firewall rules, NAT port-forwards, a load balancer or proxy — is yours to reconcile.', )}

{t('legacyProtocols.statementUndo', 'You can turn legacy protocols back on at any time.')}

); }