From ec09f3b787beba44a4ca242e382d728b6f9cde6b Mon Sep 17 00:00:00 2001 From: John Coffey Date: Fri, 30 Oct 2026 13:46:54 -0700 Subject: [PATCH 1/2] Relays: the Relays page Settings > Mail flow > Sending gets Relays, beside Routes (relay spec, "inbuxa-admin"). It lists this server's inbuxa Relays, and says something about one only when it needs attention: a join still waiting or run out, a node with no link, a spool filling up, a certificate running out, an error the relay reports, nodes sending different recipient lists. Adding a relay shows its join token once, with the command to run on the relay host. A relay's settings are edited here and pushed to it, each with its help. Its queues are read over the server's link, where held mail can be discarded and outbound mail cancelled, with a reason for the audit log. A new join and removing a relay ask first. "Routes and relays" is now "Routes", so the two pages side by side read as two. --- src/components/layout/MainContent.tsx | 4 + src/features/relays/RelaysPage.tsx | 1037 +++++++++++++++++++++++++ src/features/relays/api.ts | 138 ++++ src/features/relays/model.test.ts | 240 ++++++ src/features/relays/model.ts | 336 ++++++++ src/help/texts.ts | 32 + src/i18n/en.json | 127 +++ src/lib/layout.ts | 1 + src/lib/layoutRules.test.ts | 1 + src/lib/settingsLayout.ts | 3 +- 10 files changed, 1918 insertions(+), 1 deletion(-) create mode 100644 src/features/relays/RelaysPage.tsx create mode 100644 src/features/relays/api.ts create mode 100644 src/features/relays/model.test.ts create mode 100644 src/features/relays/model.ts diff --git a/src/components/layout/MainContent.tsx b/src/components/layout/MainContent.tsx index 061e00f..7b1bc23 100644 --- a/src/components/layout/MainContent.tsx +++ b/src/components/layout/MainContent.tsx @@ -131,6 +131,10 @@ const CUSTOM_PAGES: Record = { () => import('@/features/dlp/RulesPage'), (m) => m.MailFlowRulesPage, ), + Relays: onDemand( + () => import('@/features/relays/RelaysPage'), + (m) => m.RelaysPage, + ), Journal: onDemand( () => import('@/features/journal/JournalPage'), (m) => m.JournalPage, diff --git a/src/features/relays/RelaysPage.tsx b/src/features/relays/RelaysPage.tsx new file mode 100644 index 0000000..de58667 --- /dev/null +++ b/src/features/relays/RelaysPage.tsx @@ -0,0 +1,1037 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * INBUXA: Settings › Mail flow › Relays (relay spec, "inbuxa-admin"): the + * inbuxa Relays this server links to. Adding one shows its join token once, + * with the command to run on the relay host. The list says something only + * when a relay needs attention. Each relay has its settings, which the + * server pushes to it, and its queues, read over the server's link, where + * held mail can be discarded and outbound mail cancelled. + */ + +import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { TFunction } from 'i18next'; +import { AlertTriangle, Check, Copy, Inbox, Loader2, Plus, RefreshCw, Send, Settings2, Trash2 } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Switch } from '@/components/ui/switch'; +import { Textarea } from '@/components/ui/textarea'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { PageHeader } from '@/components/common/PageHeader'; +import { LoadingFallback } from '@/components/common/LoadingFallback'; +import { HelpTip } from '@/help/HelpTip'; +import { fieldHelp } from '@/help/texts'; +import { useAccountStore } from '@/stores/accountStore'; +import { toast } from '@/hooks/use-toast'; +import { formatSize } from '@/features/hold/legalHold'; +import { + createRelay, + deleteRelay, + destroyRelayMessage, + fetchRelayMessages, + fetchRelays, + rejoinRelay, + RelaysUnavailable, + RelayUnreachable, + updateRelay, +} from './api'; +import { + attention, + envelopeLine, + joinCommand, + latestStatus, + settingsPatch, + toForm, + waiting, + type Attention, + type Relay, + type RelayMessage, + type SettingsForm, +} from './model'; + +type Load = { kind: 'loading' } | { kind: 'ready'; relays: Relay[] } | { kind: 'error'; message: string }; + +function when(iso?: string | null): string { + return iso ? new Date(iso).toLocaleString() : ''; +} + +function errorText(e: unknown): string { + return e instanceof Error ? e.message : String(e); +} + +function failed(title: string, e: unknown) { + toast({ variant: 'destructive', title, description: errorText(e) }); +} + +/** A label with the option's ⓘ, keyed `inbuxa:Relay.`. */ +function Field({ field, label, children }: { field: string; label: string; children: ReactNode }) { + const id = `inbuxa:Relay.${field}`; + return ( + + ); +} + +function ToggleField({ + field, + label, + checked, + onChange, + disabled, +}: { + field: string; + label: string; + checked: boolean; + onChange: (on: boolean) => void; + disabled?: boolean; +}) { + const id = `inbuxa:Relay.${field}`; + return ( +
+ + {label} + + + +
+ ); +} + +function attentionText(t: TFunction, a: Attention): string { + switch (a.kind) { + case 'joinWaiting': + return a.until + ? t('relays.att.joinWaiting', 'Waiting for the relay to join, until {{until}}.', { until: when(a.until) }) + : t('relays.att.joinWaitingNow', 'Waiting for the relay to join.'); + case 'joinExpired': + return t('relays.att.joinExpired', 'The join token ran out before the relay joined. Start a new join.'); + case 'notLinked': + return t('relays.att.notLinked', 'No node has linked to it yet.'); + case 'linkDown': + return a.error + ? t('relays.att.linkDownWhy', 'Node {{node}} has no link since {{since}}: {{error}}', { + node: a.nodeId, + since: when(a.since), + error: a.error, + }) + : t('relays.att.linkDown', 'Node {{node}} has no link since {{since}}.', { + node: a.nodeId, + since: when(a.since), + }); + case 'joinFailed': + return t('relays.att.joinFailed', 'Node {{node}} couldn’t join it{{why}}. Start a new join.', { + node: a.nodeId, + why: a.error ? `: ${a.error}` : '', + }); + case 'spoolFilling': + return t('relays.att.spool', 'Its spool is {{percent}}% full.', { percent: a.percent }); + case 'certificateExpiring': + return t('relays.att.certSoon', 'Its certificate runs out {{when}}.', { when: when(a.notAfter) }); + case 'certificateExpired': + return t('relays.att.certGone', 'Its certificate ran out {{when}}.', { when: when(a.notAfter) }); + case 'relayError': + return t('relays.att.relayError', 'The relay reports: {{error}}', { error: a.error }); + case 'recipientsDiffer': + return t('relays.att.recipients', 'Nodes sent it different recipient lists; one of them is out of date.'); + } +} + +function CopyButton({ text, label }: { text: string; label: string }) { + const { t } = useTranslation(); + const [done, setDone] = useState(false); + return ( + + ); +} + +/** The join token, once, and the one command that uses it. */ +function TokenDialog({ name, token, onClose }: { name: string; token: string; onClose: () => void }) { + const { t } = useTranslation(); + const command = joinCommand(token); + return ( + !open && onClose()}> + + + {t('relays.tokenTitle', 'Join {{name}}', { name })} + + {t( + 'relays.tokenBody', + 'Run this on the relay host within 24 hours. The token is shown only now: copy it before closing.', + )} + + +
+          {command}
+        
+ + + + +
+
+ ); +} + +function AddDialog({ onClose, onAdded }: { onClose: () => void; onAdded: (name: string, token: string) => void }) { + const { t } = useTranslation(); + const [name, setName] = useState(''); + const [address, setAddress] = useState(''); + const [port, setPort] = useState('443'); + const [hostname, setHostname] = useState(''); + const [busy, setBusy] = useState(false); + const portValid = /^\d+$/.test(port) && Number(port) >= 1 && Number(port) <= 65535; + const ready = name.trim() && address.trim() && hostname.trim() && portValid; + + const add = async () => { + setBusy(true); + try { + const { joinToken } = await createRelay({ name, address, linkPort: Number(port), hostname }); + onAdded(name.trim(), joinToken); + } catch (e) { + failed(t('relays.addFailed', 'The relay wasn’t added'), e); + } finally { + setBusy(false); + } + }; + + return ( + !open && onClose()}> + + + {t('relays.addTitle', 'Add a relay')} + + {t( + 'relays.addBody', + 'Install inbuxa-relay on the edge host first. Then add it here, and run the command shown next on that host.', + )} + + +
+ + setName(e.target.value)} autoComplete="off" /> + +
+ + setAddress(e.target.value)} + autoComplete="off" + /> + + + setPort(e.target.value)} autoComplete="off" /> + +
+ + setHostname(e.target.value)} + autoComplete="off" + /> + +
+ + + + +
+
+ ); +} + +interface General { + name: string; + description: string; + address: string; + linkPort: string; + enabled: boolean; + sendThrough: boolean; + inboundOnly: boolean; +} + +function general(relay: Relay): General { + return { + name: relay.name, + description: relay.description, + address: relay.address, + linkPort: String(relay.linkPort), + enabled: relay.enabled, + sendThrough: relay.sendThrough, + inboundOnly: relay.inboundOnly, + }; +} + +function SettingsDialog({ + relay, + canChange, + onClose, + onSaved, +}: { + relay: Relay; + canChange: boolean; + onClose: () => void; + onSaved: () => void; +}) { + const { t } = useTranslation(); + const startGeneral = useMemo(() => general(relay), [relay]); + const startForm = useMemo(() => toForm(relay.settings), [relay]); + const [g, setG] = useState(startGeneral); + const [form, setForm] = useState(startForm); + const [reason, setReason] = useState(''); + const [busy, setBusy] = useState(false); + const [invalid, setInvalid] = useState(null); + const set = (field: keyof SettingsForm) => (e: { target: { value: string } }) => + setForm({ ...form, [field]: e.target.value }); + const input = (field: keyof SettingsForm, extra: { placeholder?: string; inputMode?: 'numeric' } = {}) => ( + + ); + + const save = async () => { + const result = settingsPatch(startForm, form, relay.settings); + if ('invalid' in result) { + setInvalid(result.invalid); + return; + } + const patch: Record = { ...result.patch }; + if (g.name !== startGeneral.name) patch.name = g.name.trim(); + if (g.description !== startGeneral.description) patch.description = g.description.trim(); + if (g.address !== startGeneral.address) patch.address = g.address.trim(); + if (g.linkPort !== startGeneral.linkPort) { + if (!/^\d+$/.test(g.linkPort) || Number(g.linkPort) < 1 || Number(g.linkPort) > 65535) { + setInvalid('linkPort'); + return; + } + patch.linkPort = Number(g.linkPort); + } + for (const flag of ['enabled', 'sendThrough', 'inboundOnly'] as const) { + if (g[flag] !== startGeneral[flag]) patch[flag] = g[flag]; + } + if (Object.keys(patch).length === 0) { + onClose(); + return; + } + setBusy(true); + try { + await updateRelay(relay.id, patch, reason); + toast({ title: t('relays.saved', 'Saved. The relay gets it within seconds.') }); + onSaved(); + } catch (e) { + failed(t('relays.saveFailed', 'Nothing was saved'), e); + } finally { + setBusy(false); + } + }; + + return ( + !open && onClose()}> + + + {t('relays.settingsTitle', '{{name}} settings', { name: relay.name })} + + {t('relays.settingsBody', 'This server pushes these to the relay. The relay has no settings of its own.')} + + + +
+

{t('relays.s.general', 'The relay')}

+ + setG({ ...g, name: e.target.value })} + /> + + + setG({ ...g, description: e.target.value })} + /> + +
+ + setG({ ...g, address: e.target.value })} + /> + + + setG({ ...g, linkPort: e.target.value })} + /> + +
+ setG({ ...g, enabled })} + /> + setG({ ...g, sendThrough })} + /> + setG({ ...g, inboundOnly })} + /> +
+ +
+

{t('relays.s.receiving', 'Taking mail in')}

+ + {input('hostname', { placeholder: 'mx1.example.com' })} + + + {input('smtpListen', { placeholder: '[::]:25' })} + +
+ + {input('maxMessageSizeMb', { inputMode: 'numeric' })} + + + {input('maxRecipients', { inputMode: 'numeric' })} + +
+ +
+ + {t('relays.f.connectionsPerMinute', 'Connections a minute')} + {input('connectionsPerMinute', { inputMode: 'numeric' })} + + + {t('relays.f.messagesPerMinute', 'Messages a minute')} + {input('messagesPerMinute', { inputMode: 'numeric' })} + + + {t('relays.f.concurrentSessions', 'At once')} + {input('concurrentSessions', { inputMode: 'numeric' })} + +
+
+ + {input('dnsbl', { placeholder: 'zen.example.org' })} + +
+ + {input('liveVerdictTimeout', { placeholder: '30s' })} + + + {input('heldMaxAge', { inputMode: 'numeric' })} + + + {input('spoolMaxGb', { inputMode: 'numeric' })} + +
+
+ +
+

{t('relays.s.sending', 'Sending mail on')}

+ + {input('outboundRetry', { placeholder: '2m, 5m, 10m, 30m, 1h' })} + +
+ + {input('outboundExpiry', { placeholder: '3d' })} + + + + +
+
+ + {invalid && ( +

+ {t('relays.invalid', 'One value doesn’t read: check the field marked in red.')} +

+ )} + {canChange && ( + + )} + + + {canChange && ( + + )} + +
+
+ ); +} + +function MessageLine({ + message, + canChange, + onDestroy, +}: { + message: RelayMessage; + canChange: boolean; + onDestroy: () => void; +}) { + const { t } = useTranslation(); + const { from, to } = envelopeLine(message); + const inbound = message.direction === 'inbound'; + const stillWaiting = inbound ? [] : waiting(message); + return ( +
  • +
    +
    +

    + + {inbound ? t('relays.q.in', 'In') : t('relays.q.out', 'Out')} + + {t('relays.q.fromTo', '{{from}} to {{to}}', { from, to: to.join(', ') })} +

    +

    + {when(message.createdAt)} · {formatSize(message.size)} + {inbound && message.state && ( + <> + {' · '} + {message.state === 'held' + ? t('relays.q.held', 'waiting for this server') + : message.state === 'pending' + ? t('relays.q.pending', 'waiting for an answer') + : t('relays.q.claimed', 'being taken now')} + + )} + {!inbound && message.nextAttemptAt && ( + <> + {' · '} + {t('relays.q.next', 'next try {{when}}', { when: when(message.nextAttemptAt) })} + + )} +

    + {stillWaiting.map((w) => ( +

    + {w.reply + ? t('relays.q.reply', '{{to}}: {{reply}}', { to: w.to, reply: w.reply }) + : t('relays.q.notTried', '{{to}}: not tried yet', { to: w.to })} +

    + ))} +
    + {canChange && message.state !== 'claimed' && ( + + )} +
    +
  • + ); +} + +function DestroyDialog({ + message, + onClose, + onDone, +}: { + message: RelayMessage; + onClose: () => void; + onDone: () => void; +}) { + const { t } = useTranslation(); + const [reason, setReason] = useState(''); + const [busy, setBusy] = useState(false); + const inbound = message.direction === 'inbound'; + const run = async () => { + setBusy(true); + try { + await destroyRelayMessage(message.id, reason); + toast({ title: inbound ? t('relays.q.discarded', 'Discarded') : t('relays.q.cancelled', 'Cancelled') }); + onDone(); + } catch (e) { + failed(t('relays.q.destroyFailed', 'Nothing was changed'), e); + } finally { + setBusy(false); + } + }; + return ( + !open && onClose()}> + + + + {inbound + ? t('relays.q.discardTitle', 'Discard this message?') + : t('relays.q.cancelTitle', 'Cancel this message?')} + + + {inbound + ? t( + 'relays.q.discardBody', + 'It’s deleted at the relay and never reaches anyone here. The sender isn’t told.', + ) + : t( + 'relays.q.cancelBody', + 'It isn’t delivered to the recipients still waiting, and the sender isn’t told. If the relay is sending it right now, it stops after this attempt.', + )} + + +