The standard mail ports as one table, above Listeners #47

Merged
jcoffey-dev merged 1 commits from feature/ports-table into main 2026-09-28 22:11:48 +00:00
5 changed files with 442 additions and 0 deletions

No files matched your search

+4
View File
@@ -17,6 +17,7 @@ import { DynamicForm } from '@/components/forms/DynamicForm';
import { DynamicViewPage } from '@/components/views/DynamicViewPage';
import { LoadingFallback } from '@/components/common/LoadingFallback';
import { LegacyProtocolsBanner } from '@/features/hardening/LegacyProtocolsBanner';
import { ListenersPage } from '@/features/ports/ListenersPage';
import { MetricsPage } from '@/features/metricsConnect/MetricsPage';
import { AlertsPage } from '@/features/alerts/AlertsPage';
import { ReportsPage } from '@/features/reports/ReportsPage';
@@ -367,5 +368,8 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti
if (viewName === 'x:Alert') {
return <AlertsPage viewName={viewName} />;
}
if (viewName === 'x:NetworkListener') {
return <ListenersPage viewName={viewName} />;
}
return <DynamicList viewName={viewName} />;
}
+20
View File
@@ -0,0 +1,20 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useState } from 'react';
import { DynamicList } from '@/components/lists/DynamicList';
import { PortsTable } from './PortsTable';
/** Network › Listeners: the standard ports as a table, then every listener, refreshed on a change. */
export function ListenersPage({ viewName }: { viewName: string }) {
const [version, setVersion] = useState(0);
return (
<div className="space-y-4">
<PortsTable onChanged={() => setVersion((v) => v + 1)} />
<DynamicList key={version} viewName={viewName} />
</div>
);
}
+246
View File
@@ -0,0 +1,246 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* inbuxa: above Network › Listeners, the standard mail ports as one table
* (settings-reorg, second wave): what each is for, its encryption, whether
* it's open, and a switch. IMAP, POP3 and ManageSieve (and the sending
* ports when legacy mail is off) are switched on Hardening, so their rows
* link there. Custom listeners stay in the list below.
*/
import { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Loader2, Network } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { getAccountId, jmapGet, jmapSet } from '@/services/jmap/client';
import { useAccountStore } from '@/stores/accountStore';
import { useSchemaStore } from '@/stores/schemaStore';
import type { JmapSetResponse } from '@/types/jmap';
import { toast } from '@/hooks/use-toast';
import { fetchProtocolPolicy, type ProtocolPolicy } from '@/features/hardening/protocolPolicy';
import { cn } from '@/lib/utils';
import {
classify,
encryptionWords,
listenerFor,
portsOf,
SERVICES,
type ListenerRecord,
type Service,
} from './services';
const OBJECT = 'x:NetworkListener';
export function PortsTable({ onChanged }: { onChanged?: () => void }) {
const { t } = useTranslation();
const canCreate = useAccountStore((s) => s.hasObjectPermission('sysNetworkListener', 'Create'));
const canDestroy = useAccountStore((s) => s.hasObjectPermission('sysNetworkListener', 'Destroy'));
const hardeningSection = useSchemaStore((s) => s.viewToSection['CustomComponent/LegacyProtocols']) ?? 'Settings';
const [listeners, setListeners] = useState<ListenerRecord[] | null>(null);
const [policy, setPolicy] = useState<ProtocolPolicy | null>(null);
const [confirming, setConfirming] = useState<string | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const load = useCallback(async () => {
const [res] = await jmapGet(OBJECT, getAccountId(OBJECT), null, [
'name',
'protocol',
'bind',
'tlsImplicit',
'useTls',
]);
setListeners(((res?.[1] as { list?: ListenerRecord[] })?.list ?? []) as ListenerRecord[]);
}, []);
useEffect(() => {
let live = true;
jmapGet(OBJECT, getAccountId(OBJECT), null, ['name', 'protocol', 'bind', 'tlsImplicit', 'useTls'])
.then(
([res]) => live && setListeners(((res?.[1] as { list?: ListenerRecord[] })?.list ?? []) as ListenerRecord[]),
)
.catch(() => undefined);
// An older server has no protocol policy: then nothing is switched on Hardening.
fetchProtocolPolicy()
.then((p) => live && setPolicy(p))
.catch(() => undefined);
return () => {
live = false;
};
}, []);
if (!listeners) return null;
const { byService, custom } = classify(listeners);
const sendingOnHardening = policy?.legacyProtocols === 'disabled' && policy.closeSubmission;
const onHardening = (s: Service) => s.hardening || (s.group === 'send' && sendingOnHardening);
const label: Record<string, string> = {
smtp: t('ports.smtp', 'Receiving mail from other servers'),
submission: t('ports.submission', 'Sending from mail apps'),
submissions: t('ports.submissions', 'Sending from mail apps'),
https: t('ports.https', 'Webmail, this console, JMAP apps, automatic setup'),
http: t('ports.http', 'Redirect to HTTPS, and HTTP certificate checks'),
imaps: t('ports.imaps', 'Reading mail in IMAP apps'),
imap: t('ports.imap', 'Reading mail in IMAP apps'),
pop3s: t('ports.pop3s', 'Downloading mail with POP3'),
pop3: t('ports.pop3', 'Downloading mail with POP3'),
sieve: t('ports.sieve', 'Editing filters from mail apps (ManageSieve)'),
};
const turnOn = async (s: Service) => {
setBusy(s.id);
try {
const [res] = await jmapSet(OBJECT, getAccountId(OBJECT), { create: { l: listenerFor(s) } });
const body = res?.[1] as unknown as JmapSetResponse | undefined;
const bad = body?.notCreated?.l;
if (bad || !body?.created?.l) throw new Error(bad?.description ?? bad?.type ?? 'Not created');
await load();
onChanged?.();
} catch (e) {
toast({
variant: 'destructive',
title: t('ports.failed', 'Not changed'),
description: e instanceof Error ? e.message : String(e),
});
} finally {
setBusy(null);
}
};
const turnOff = async (s: Service, ls: ListenerRecord[]) => {
setBusy(s.id);
setConfirming(null);
try {
const [res] = await jmapSet(OBJECT, getAccountId(OBJECT), { destroy: ls.map((l) => l.id) });
const body = res?.[1] as unknown as JmapSetResponse | undefined;
const bad = Object.values(body?.notDestroyed ?? {})[0];
if (bad) throw new Error(bad.description ?? bad.type);
await load();
onChanged?.();
} catch (e) {
toast({
variant: 'destructive',
title: t('ports.failed', 'Not changed'),
description: e instanceof Error ? e.message : String(e),
});
} finally {
setBusy(null);
}
};
const row = (s: Service) => {
const ls = byService.get(s.id) ?? [];
const on = ls.length > 0;
return (
<tr key={s.id} className="align-top">
<td className="py-2 pr-3">
<span className="font-mono">{s.port}</span>
</td>
<td className="py-2 pr-3">{label[s.id]}</td>
<td className="py-2 pr-3 text-muted-foreground">
{encryptionWords(on ? ls[0] : { tlsImplicit: s.implicitTls, useTls: s.useTls })}
</td>
<td className={cn('py-2 pr-3', on ? 'text-emerald-600 dark:text-emerald-400' : 'text-muted-foreground')}>
{on ? t('ports.open', 'open') : t('ports.closed', 'closed')}
</td>
<td className="py-2 text-right">
{onHardening(s) ? (
<Link
className="text-xs text-primary hover:underline"
to={`/${hardeningSection}/CustomComponent/LegacyProtocols`}
>
{t('ports.onHardening', 'Switched on Hardening')}
</Link>
) : confirming === s.id ? (
<span className="flex flex-wrap items-center justify-end gap-2 text-xs">
<span className="text-destructive">{s.critical}</span>
<Button type="button" size="sm" variant="destructive" className="h-7" onClick={() => void turnOff(s, ls)}>
{t('ports.confirmOff', 'Close it')}
</Button>
<Button type="button" size="sm" variant="ghost" className="h-7" onClick={() => setConfirming(null)}>
{t('common.cancel', 'Cancel')}
</Button>
</span>
) : on ? (
<Button
type="button"
size="sm"
variant="outline"
className="h-7"
disabled={!canDestroy || busy !== null}
onClick={() => (s.critical ? setConfirming(s.id) : void turnOff(s, ls))}
>
{busy === s.id && <Loader2 className="h-3 w-3 animate-spin" />}
{t('ports.close', 'Close')}
</Button>
) : (
<Button
type="button"
size="sm"
variant="outline"
className="h-7"
disabled={!canCreate || busy !== null}
onClick={() => void turnOn(s)}
>
{busy === s.id && <Loader2 className="h-3 w-3 animate-spin" />}
{t('ports.openIt', 'Open')}
</Button>
)}
</td>
</tr>
);
};
return (
<div className="space-y-3 rounded-xl border bg-card p-4">
<div className="flex items-center gap-3">
<Network className="h-5 w-5 text-primary" />
<p className="font-medium">{t('ports.title', 'The standard ports')}</p>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs text-muted-foreground">
<th className="py-1 pr-3 font-medium">{t('ports.port', 'Port')}</th>
<th className="py-1 pr-3 font-medium">{t('ports.for', 'For')}</th>
<th className="py-1 pr-3 font-medium">{t('ports.encryption', 'Encryption')}</th>
<th className="py-1 pr-3 font-medium">{t('ports.state', 'State')}</th>
<th />
</tr>
</thead>
<tbody className="divide-y">{SERVICES.map(row)}</tbody>
</table>
</div>
{custom.length > 0 && (
<div className="space-y-1 text-sm">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{t('ports.custom', 'Other listeners')}
</p>
<ul className="space-y-0.5">
{custom.map((l) => (
<li key={l.id}>
{t('ports.customLine', '{{name}}: {{protocol}} on port {{ports}}, {{tls}}.', {
name: l.name ?? l.id,
protocol: l.protocol ?? 'smtp',
ports: portsOf(l).join(', ') || '?',
tls: encryptionWords(l),
})}
</li>
))}
</ul>
</div>
)}
<p className="text-xs text-muted-foreground">
{t(
'ports.reach',
'Open here means the server listens. Whether the internet can reach it also depends on your firewall and hosting provider.',
)}
</p>
</div>
);
}
+47
View File
@@ -0,0 +1,47 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, it, expect } from 'vitest';
import { classify, encryptionWords, listenerFor, portOf, SERVICES } from './services';
describe('ports', () => {
it('reads ports from socket addresses', () => {
expect(portOf('[::]:25')).toBe(25);
expect(portOf('0.0.0.0:587')).toBe(587);
expect(portOf('localhost')).toBeNull();
});
it('sorts listeners into services by protocol and port, and keeps the rest as custom', () => {
const { byService, custom } = classify([
{ id: 'a', protocol: 'smtp', bind: { '[::]:25': true } },
{ id: 'b', protocol: 'smtp', bind: { '[::]:465': true }, tlsImplicit: true },
{ id: 'c', protocol: 'http', bind: { '[::]:443': true, '0.0.0.0:443': true }, tlsImplicit: true },
{ id: 'd', protocol: 'http', bind: { '127.0.0.1:8080': true } },
{ id: 'e', protocol: 'imap', bind: { '[::]:25': true } },
]);
expect(byService.get('smtp')?.map((l) => l.id)).toEqual(['a']);
expect(byService.get('submissions')?.map((l) => l.id)).toEqual(['b']);
expect(byService.get('https')?.map((l) => l.id)).toEqual(['c']);
expect(custom.map((l) => l.id)).toEqual(['d', 'e']);
});
it('makes a listener for a service', () => {
const s = SERVICES.find((x) => x.id === 'submissions')!;
expect(listenerFor(s)).toEqual({
name: 'submissions',
protocol: 'smtp',
bind: { '[::]:465': true },
tlsImplicit: true,
useTls: true,
});
});
it('names the encryption', () => {
expect(encryptionWords({ tlsImplicit: true })).toBe('encrypted from the start');
expect(encryptionWords({ useTls: true })).toBe('STARTTLS');
expect(encryptionWords({ useTls: false })).toBe('not encrypted');
});
});
+125
View File
@@ -0,0 +1,125 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* inbuxa: the standard mail ports as services (settings-reorg, second wave).
* A listener is one x:NetworkListener: a protocol, the addresses it binds
* and how TLS is used. The table sorts them into the services people know
* by their port; anything else is a custom listener, left as it is.
*
* IMAP, POP3 and ManageSieve (and, when legacy mail is off entirely, the
* sending ports) belong to Security › Hardening, whose switches take their
* listeners away and put them back (legacy-protocols spec). The table
* shows their state and points there instead of switching them itself.
*/
export type ServiceId =
'smtp' | 'submission' | 'submissions' | 'https' | 'http' | 'imaps' | 'imap' | 'pop3s' | 'pop3' | 'sieve';
export interface Service {
id: ServiceId;
port: number;
protocol: string;
implicitTls: boolean;
/** Whether STARTTLS is offered (useTls without implicit TLS). */
useTls: boolean;
group: 'receive' | 'send' | 'web' | 'read';
/** Switched on Hardening, not here. */
hardening?: boolean;
/** Turning it off breaks something people rely on: ask first. */
critical?: string;
}
export const SERVICES: Service[] = [
{
id: 'smtp',
port: 25,
protocol: 'smtp',
implicitTls: false,
useTls: true,
group: 'receive',
critical: 'Other servers can no longer deliver mail to you.',
},
{ id: 'submission', port: 587, protocol: 'smtp', implicitTls: false, useTls: true, group: 'send' },
{ id: 'submissions', port: 465, protocol: 'smtp', implicitTls: true, useTls: true, group: 'send' },
{
id: 'https',
port: 443,
protocol: 'http',
implicitTls: true,
useTls: true,
group: 'web',
critical: 'The webmail, this console, JMAP apps and automatic setup stop working.',
},
{ id: 'http', port: 80, protocol: 'http', implicitTls: false, useTls: false, group: 'web' },
{ id: 'imaps', port: 993, protocol: 'imap', implicitTls: true, useTls: true, group: 'read', hardening: true },
{ id: 'imap', port: 143, protocol: 'imap', implicitTls: false, useTls: true, group: 'read', hardening: true },
{ id: 'pop3s', port: 995, protocol: 'pop3', implicitTls: true, useTls: true, group: 'read', hardening: true },
{ id: 'pop3', port: 110, protocol: 'pop3', implicitTls: false, useTls: true, group: 'read', hardening: true },
{
id: 'sieve',
port: 4190,
protocol: 'manageSieve',
implicitTls: false,
useTls: true,
group: 'read',
hardening: true,
},
];
export interface ListenerRecord {
id: string;
name?: string;
protocol?: string;
bind?: Record<string, boolean>;
tlsImplicit?: boolean;
useTls?: boolean;
}
/** The port of a socket address: "[::]:25", "0.0.0.0:587", "127.0.0.1:8080". */
export function portOf(address: string): number | null {
const m = /:(\d+)$/.exec(address.trim());
return m ? Number(m[1]) : null;
}
export function portsOf(l: ListenerRecord): number[] {
return Object.keys(l.bind ?? {})
.map(portOf)
.filter((p): p is number => p !== null);
}
/** Which listeners serve which standard service, and which are custom. */
export function classify(listeners: ListenerRecord[]): {
byService: Map<ServiceId, ListenerRecord[]>;
custom: ListenerRecord[];
} {
const byService = new Map<ServiceId, ListenerRecord[]>();
const custom: ListenerRecord[] = [];
for (const l of listeners) {
const ports = portsOf(l);
const s = SERVICES.find((x) => x.protocol === (l.protocol ?? 'smtp') && ports.includes(x.port));
if (s) byService.set(s.id, [...(byService.get(s.id) ?? []), l]);
else custom.push(l);
}
return { byService, custom };
}
/** A new listener for a standard service, on every address. */
export function listenerFor(s: Service): Record<string, unknown> {
return {
name: s.id,
protocol: s.protocol,
bind: { [`[::]:${s.port}`]: true },
tlsImplicit: s.implicitTls,
useTls: s.useTls,
};
}
export function encryptionWords(l: { tlsImplicit?: boolean; useTls?: boolean }): string {
if (l.tlsImplicit) return 'encrypted from the start';
if (l.useTls === false) return 'not encrypted';
return 'STARTTLS';
}