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..b044772 --- /dev/null +++ b/src/features/relays/RelaysPage.tsx @@ -0,0 +1,1043 @@ +/* + * 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); +} + +/** Dialogs open on their first field, not on its ⓘ, whose help would pop open. */ +function focusFirstField(e: Event) { + e.preventDefault(); + (e.target as HTMLElement | null)?.querySelector('input, textarea')?.focus(); +} + +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. A linked 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.', + )} + + +