From b1d833bbe48af7c22d122418a28da2a9725ed35a Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sun, 18 Oct 2026 19:48:14 -0700 Subject: [PATCH] Rewrite the delivery trace page The trace's logic is now plain, tested code: trace.ts pairs each step's start with its outcome, groups steps by delivery attempt, names and describes them, and holds the trace's states as a reducer; stream.ts runs the token and the event stream. The page only draws them, unchanged. Two fixes on the way: "Started Ns ago" counts from the trace's own start (it carried on from the previous trace before), and cancelling while the token is still on its way no longer opens the stream afterwards. --- .../troubleshoot/DeliveryTracePage.tsx | 820 ++++++------------ src/features/troubleshoot/stream.test.ts | 138 +++ src/features/troubleshoot/stream.ts | 83 ++ src/features/troubleshoot/trace.test.ts | 223 +++++ src/features/troubleshoot/trace.ts | 224 +++++ src/features/troubleshoot/types.ts | 27 +- 6 files changed, 962 insertions(+), 553 deletions(-) create mode 100644 src/features/troubleshoot/stream.test.ts create mode 100644 src/features/troubleshoot/stream.ts create mode 100644 src/features/troubleshoot/trace.test.ts create mode 100644 src/features/troubleshoot/trace.ts diff --git a/src/features/troubleshoot/DeliveryTracePage.tsx b/src/features/troubleshoot/DeliveryTracePage.tsx index efe3b69..e5baf8b 100644 --- a/src/features/troubleshoot/DeliveryTracePage.tsx +++ b/src/features/troubleshoot/DeliveryTracePage.tsx @@ -1,253 +1,160 @@ /* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL - * - * Modified by Coffey Labs in 2026 for INBUXA. + * SPDX-License-Identifier: AGPL-3.0-only */ -import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; -import { useSearchParams } from 'react-router-dom'; -import { isValidTarget } from './target'; +import { useCallback, useEffect, useMemo, useReducer, useRef, useState, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; +import { useSearchParams } from 'react-router-dom'; import { - Play, - StopCircle, - Loader2, + ChevronRight, CircleCheck, CircleX, - TriangleAlert, - ChevronRight, - Mail, Globe, + Loader2, + Mail, + Play, + StopCircle, + TriangleAlert, } from 'lucide-react'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Card, CardContent } from '@/components/ui/card'; import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; -import { apiFetch, getApiBaseUrl } from '@/services/api'; -import type { DeliveryStage, MX, ReportUri, StageSeverity } from './types'; -import { stageSeverity } from './types'; +import { Input } from '@/components/ui/input'; +import { streamDeliveryTrace, type StreamFailure } from './stream'; +import { isValidTarget } from './target'; +import { + byAttempt, + formatElapsed, + outcome, + pairStages, + stageDetail, + stageLabel, + stageSeverity, + traceReducer, + type StageSeverity, +} from './trace'; +import type { DeliveryStage, MX, ReportUri } from './types'; -function SimpleAlert({ variant, children }: { variant: 'default' | 'destructive'; children: React.ReactNode }) { - return ( -
- {children} -
- ); -} +/** Runs one trace at a time, stopping it when the page goes away. */ +function useDeliveryTrace() { + const { t } = useTranslation(); + const [state, dispatch] = useReducer(traceReducer, { kind: 'idle' }); + const stop = useRef<(() => void) | null>(null); -type TraceState = - | { kind: 'idle' } - | { kind: 'starting'; target: string } - | { kind: 'running'; target: string; startedAt: Date; events: DeliveryStage[]; eventSource: EventSource } - | { kind: 'completed'; target: string; startedAt: Date; completedAt: Date; events: DeliveryStage[] } - | { kind: 'failed'; target: string; events: DeliveryStage[]; error: string }; + const halt = useCallback(() => { + stop.current?.(); + stop.current = null; + }, []); + useEffect(() => halt, [halt]); -function phaseKey(stage: DeliveryStage): string { - const t = stage.type; - if (t.startsWith('mxLookup')) return 'mxLookup'; - if (t.startsWith('mtaStsFetch') || t === 'mtaStsNotFound') return 'mtaStsFetch'; - if (t.startsWith('tlsRptLookup') || t === 'tlsRptNotFound') return 'tlsRptLookup'; - if (t.startsWith('mtaStsVerify')) return 'mtaStsVerify'; - if (t.startsWith('tlsaLookup') || t === 'tlsaNotFound') return 'tlsaLookup'; - if (t.startsWith('ipLookup')) return 'ipLookup'; - if (t.startsWith('connection')) return 'connection'; - if (t.startsWith('readGreeting')) return 'readGreeting'; - if (t.startsWith('ehlo')) return 'ehlo'; - if (t.startsWith('startTls')) return 'startTls'; - if (t.startsWith('daneVerify')) return 'daneVerify'; - if (t.startsWith('mailFrom')) return 'mailFrom'; - if (t.startsWith('rcptTo')) return 'rcptTo'; - if (t.startsWith('quit')) return 'quit'; - if (t === 'deliveryAttemptStart') return `attempt-${(stage as { hostname: string }).hostname}`; - return t; -} - -type TFn = (key: string, fallback: string, options?: Record) => string; - -function phaseLabelFor(t: TFn, pk: string): string { - switch (pk) { - case 'mxLookup': - return t('deliveryTrace.phase.mxLookup', 'MX Lookup'); - case 'mtaStsFetch': - return t('deliveryTrace.phase.mtaStsFetch', 'MTA-STS Policy Fetch'); - case 'tlsRptLookup': - return t('deliveryTrace.phase.tlsRptLookup', 'TLS-RPT Lookup'); - case 'mtaStsVerify': - return t('deliveryTrace.phase.mtaStsVerify', 'MTA-STS Verify'); - case 'tlsaLookup': - return t('deliveryTrace.phase.tlsaLookup', 'TLSA / DANE Lookup'); - case 'ipLookup': - return t('deliveryTrace.phase.ipLookup', 'IP Lookup'); - case 'connection': - return t('deliveryTrace.phase.connection', 'TCP Connection'); - case 'readGreeting': - return t('deliveryTrace.phase.readGreeting', 'Read Greeting'); - case 'ehlo': - return 'EHLO'; - case 'startTls': - return 'STARTTLS'; - case 'daneVerify': - return t('deliveryTrace.phase.daneVerify', 'DANE Verify'); - case 'mailFrom': - return 'MAIL FROM'; - case 'rcptTo': - return 'RCPT TO'; - case 'quit': - return 'QUIT'; - default: - return pk; - } -} - -function phaseLabel(t: TFn, stage: DeliveryStage): string { - if (stage.type === 'deliveryAttemptStart') { - return t('deliveryTrace.attemptLabel', 'Delivery Attempt: {{hostname}}', { - hostname: (stage as { hostname: string }).hostname, - }); - } - const pk = phaseKey(stage); - return phaseLabelFor(t, pk); -} - -function SeverityIcon({ severity }: { severity: StageSeverity }) { - switch (severity) { - case 'pending': - return ; - case 'ok': - return ; - case 'warn': - return ; - case 'error': - return ; - } -} - -function formatElapsed(ms: number): string { - if (ms < 1000) return `${Math.round(ms)} ms`; - return `${(ms / 1000).toFixed(1)} s`; -} - -function eventBodyText(t: TFn, stage: DeliveryStage): string | null { - const stageType = stage.type; - switch (stageType) { - case 'mxLookupSuccess': { - const total = stage.mxs.reduce((n, mx) => n + mx.exchanges.length, 0); - return total === 1 - ? t('deliveryTrace.body.mxLookupSuccess_one', 'Resolved {{count}} MX record', { count: total }) - : t('deliveryTrace.body.mxLookupSuccess_other', 'Resolved {{count}} MX records', { count: total }); - } - case 'mxLookupStart': - return t('deliveryTrace.body.mxLookupStart', 'Looking up MX for {{domain}}', { domain: stage.domain }); - case 'mtaStsFetchSuccess': - return t('deliveryTrace.body.mtaStsFetchSuccess', 'Policy fetched successfully'); - case 'mtaStsNotFound': - return t('deliveryTrace.body.mtaStsNotFound', 'No MTA-STS policy published'); - case 'tlsRptLookupSuccess': { - const n = stage.rua.length; - return n === 1 - ? t('deliveryTrace.body.tlsRptLookupSuccess_one', 'Found {{count}} reporting URI', { count: n }) - : t('deliveryTrace.body.tlsRptLookupSuccess_other', 'Found {{count}} reporting URIs', { count: n }); - } - case 'tlsRptNotFound': - return t('deliveryTrace.body.tlsRptNotFound', 'No TLS-RPT record published'); - case 'ipLookupSuccess': { - const ips = stage.remoteIps; - return ips.length <= 3 - ? ips.join(', ') - : t('deliveryTrace.body.ipLookupOverflow', '{{shown}} +{{extra}} more', { - shown: ips.slice(0, 3).join(', '), - extra: ips.length - 3, - }); - } - case 'connectionStart': - return t('deliveryTrace.body.connectionStart', 'Connecting to {{ip}}', { ip: stage.remoteIp }); - case 'connectionSuccess': - return t('deliveryTrace.body.connectionSuccess', 'Connected'); - case 'tlsaLookupSuccess': - return t('deliveryTrace.body.tlsaLookupSuccess', 'TLSA record found'); - case 'tlsaNotFound': - return stage.reason || t('deliveryTrace.body.tlsaNotFound', 'No TLSA record found'); - case 'readGreetingSuccess': - return t('deliveryTrace.body.readGreetingSuccess', 'Server greeting received'); - case 'ehloSuccess': - return t('deliveryTrace.body.ehloSuccess', 'EHLO accepted'); - case 'startTlsSuccess': - return t('deliveryTrace.body.startTlsSuccess', 'TLS negotiated'); - case 'daneVerifySuccess': - return t('deliveryTrace.body.daneVerifySuccess', 'DANE verification passed'); - case 'mtaStsVerifySuccess': - return t('deliveryTrace.body.mtaStsVerifySuccess', 'MTA-STS hostname verified'); - case 'mailFromSuccess': - return t('deliveryTrace.body.mailFromSuccess', 'Sender accepted'); - case 'rcptToSuccess': - return t('deliveryTrace.body.rcptToSuccess', 'Recipient accepted'); - case 'quitCompleted': - return t('deliveryTrace.body.quitCompleted', 'Session closed'); - default: - if ('reason' in stage && typeof (stage as { reason?: string }).reason === 'string') { - return (stage as { reason: string }).reason; + const failure = useCallback( + (f: StreamFailure) => { + switch (f.reason) { + case 'lost': + return t('deliveryTrace.connectionLost', 'Connection lost before trace completed.'); + case 'timeout': + return t('deliveryTrace.timedOut', 'Trace timed out after 120 seconds.'); + case 'start': + return f.message ?? t('deliveryTrace.failedToStart', 'Failed to start trace.'); } - return null; - } + }, + [t], + ); + + const start = useCallback( + (target: string) => { + halt(); + dispatch({ type: 'start', target }); + stop.current = streamDeliveryTrace(target, { + opened: (at) => dispatch({ type: 'opened', at }), + stages: (stages, at) => dispatch({ type: 'stages', stages, at }), + failed: (f) => dispatch({ type: 'failed', error: failure(f) }), + }); + }, + [halt, failure], + ); + + const reset = useCallback(() => { + halt(); + dispatch({ type: 'reset' }); + }, [halt]); + + return { state, start, reset }; } -function MxTable({ mxs }: { mxs: MX[] }) { - const sorted = [...mxs].sort((a, b) => a.preference - b.preference); +/** Whole seconds since a moment, ticking once a second. */ +function useSecondsSince(since: number): number { + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + const id = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(id); + }, []); + return Math.max(0, Math.floor((now - since) / 1000)); +} + +function Notice({ tone, children }: { tone: 'default' | 'destructive'; children: ReactNode }) { + const look = + tone === 'destructive' ? 'border-destructive/50 bg-destructive/10 text-destructive' : 'border-border bg-card'; + return
{children}
; +} + +const SEVERITY_ICON: Record = { + pending: , + ok: , + warn: , + error: , +}; + +/** MX hosts, most preferred first. */ +function MxHosts({ mxs }: { mxs: MX[] }) { + const rows = [...mxs] + .sort((a, b) => a.preference - b.preference) + .flatMap((mx) => mx.exchanges.map((host) => ({ preference: mx.preference, host }))); return (
- {sorted.map((mx, i) => - mx.exchanges.map((ex, j) => ( -
- {mx.preference} - {ex} -
- )), - )} -
- ); -} - -function ReportUris({ rua }: { rua: ReportUri[] }) { - return ( -
- {rua.map((uri, i) => ( -
- {uri.type === 'mail' ? ( - <> - - {uri.email} - - ) : ( - <> - - {uri.url} - - )} + {rows.map((row, i) => ( +
+ {row.preference} + {row.host}
))}
); } -function OpaqueObject({ data }: { data: Record }) { +function ReportDestinations({ rua }: { rua: ReportUri[] }) { + return ( +
+ {rua.map((uri, i) => ( +
+ {uri.type === 'mail' ? ( + + ) : ( + + )} + {uri.type === 'mail' ? uri.email : uri.url} +
+ ))} +
+ ); +} + +/** A policy or record as the server sent it: one line per field, nested values as JSON. */ +function RawFields({ data }: { data: Record }) { return (
- {Object.entries(data).map(([k, v]) => ( -
-
{k}
+ {Object.entries(data).map(([name, value]) => ( +
+
{name}
- {typeof v === 'object' && v !== null ? ( -
{JSON.stringify(v, null, 2)}
+ {value !== null && typeof value === 'object' ? ( +
{JSON.stringify(value, null, 2)}
) : ( - String(v) + String(value) )}
@@ -257,409 +164,244 @@ function OpaqueObject({ data }: { data: Record }) { ); } -function EventRow({ stage }: { stage: DeliveryStage }) { +/** What a step found, for the steps that found something worth opening. */ +function stepDetails(stage: DeliveryStage): ReactNode { + switch (stage.type) { + case 'mxLookupSuccess': + return ; + case 'tlsRptLookupSuccess': + return ; + case 'mtaStsFetchSuccess': + return ; + case 'tlsaLookupSuccess': + return ; + default: + return null; + } +} + +function StepRow({ stage }: { stage: DeliveryStage }) { const { t } = useTranslation(); const severity = stageSeverity(stage); - const body = eventBodyText(t, stage); - const elapsed = 'elapsed' in stage ? (stage as { elapsed: number }).elapsed : null; - const hasDetails = - stage.type === 'mxLookupSuccess' || - stage.type === 'mtaStsFetchSuccess' || - stage.type === 'tlsRptLookupSuccess' || - stage.type === 'tlsaLookupSuccess'; + const detail = stageDetail(t, stage); + const details = stepDetails(stage); - const content = ( + const row = (
-
- -
+
{SEVERITY_ICON[severity]}
- {phaseLabel(t, stage)} - {elapsed !== null && ( + {stageLabel(t, stage)} + {'elapsed' in stage && ( - {formatElapsed(elapsed)} + {formatElapsed(stage.elapsed)} )}
- {body && ( -

{body}

+ {detail && ( +

+ {detail} +

)}
); - - if (!hasDetails) return content; + if (!details) return row; return ( - {content} + {row} - - {stage.type === 'mxLookupSuccess' && } - {stage.type === 'mtaStsFetchSuccess' && } - {stage.type === 'tlsRptLookupSuccess' && } - {stage.type === 'tlsaLookupSuccess' && } - + {details} ); } -function useRelativeTime(startedAtMs: number | null): string { +interface TraceFormProps { + target: string; + onTargetChange: (target: string) => void; + starting: boolean; + onStart: (target: string) => void; +} + +function TraceForm({ target, onTargetChange, starting, onStart }: TraceFormProps) { const { t } = useTranslation(); - const [tick, setTick] = useState(0); + const [invalid, setInvalid] = useState(false); - useEffect(() => { - if (startedAtMs === null) return; - const id = setInterval(() => setTick((prev) => prev + 1), 1000); - return () => clearInterval(id); - }, [startedAtMs]); + const submit = () => { + const value = target.trim(); + if (isValidTarget(value)) onStart(value); + else setInvalid(true); + }; - if (startedAtMs === null) return ''; - const s = tick; - if (s < 60) return t('deliveryTrace.startedAgoSec', 'Started {{s}}s ago', { s }); - return t('deliveryTrace.startedAgoMin', 'Started {{m}}m {{s}}s ago', { - m: Math.floor(s / 60), - s: s % 60, - }); + return ( +
+
+

{t('deliveryTrace.title', 'Delivery Trace')}

+

+ {t( + 'deliveryTrace.subtitle', + 'Run a real outbound SMTP delivery attempt and watch every DNS lookup, TLS handshake, and SMTP command as it happens.', + )} +

+
+ + +
+ +
+ { + onTargetChange(e.target.value); + setInvalid(false); + }} + onKeyDown={(e) => e.key === 'Enter' && submit()} + autoFocus + className="flex-1" + /> + +
+ {invalid && ( +

+ {t('deliveryTrace.invalidTarget', 'Enter a valid email address or domain.')} +

+ )} +
+
+
+
+ ); +} + +function StartedAgo({ since }: { since: number }) { + const { t } = useTranslation(); + const seconds = useSecondsSince(since); + return ( +

+ {seconds < 60 + ? t('deliveryTrace.startedAgoSec', 'Started {{s}}s ago', { s: seconds }) + : t('deliveryTrace.startedAgoMin', 'Started {{m}}m {{s}}s ago', { + m: Math.floor(seconds / 60), + s: seconds % 60, + })} +

+ ); } export function DeliveryTracePage() { const { t } = useTranslation(); const [searchParams] = useSearchParams(); - const [inputValue, setInputValue] = useState(searchParams.get('target') ?? ''); - const [inputError, setInputError] = useState(null); - const [state, setState] = useState({ kind: 'idle' }); - const eventSourceRef = useRef(null); - const timeoutRef = useRef | null>(null); - - const relTime = useRelativeTime(state.kind === 'running' ? state.startedAt.getTime() : null); - - useEffect(() => { - return () => { - if (eventSourceRef.current) { - eventSourceRef.current.close(); - eventSourceRef.current = null; - } - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } - }; - }, []); - - const startTrace = useCallback(async () => { - const target = inputValue.trim(); - if (!isValidTarget(target)) { - setInputError(t('deliveryTrace.invalidTarget', 'Enter a valid email address or domain.')); - return; - } - setInputError(null); - - setState({ kind: 'starting', target }); - - try { - const tokenResponse = await apiFetch('/api/token/delivery'); - const token = await tokenResponse.text(); - - const baseUrl = getApiBaseUrl(); - const url = `${baseUrl}/api/live/delivery/${encodeURIComponent(target)}?token=${encodeURIComponent(token)}`; - - const es = new EventSource(url); - eventSourceRef.current = es; - - const events: DeliveryStage[] = []; - const startedAt = new Date(); - - timeoutRef.current = setTimeout(() => { - es.close(); - eventSourceRef.current = null; - setState({ - kind: 'failed', - target, - events: [...events], - error: t('deliveryTrace.timedOut', 'Trace timed out after 120 seconds.'), - }); - }, 120_000); - - es.addEventListener('event', (event) => { - try { - const batch = JSON.parse(event.data) as DeliveryStage[]; - for (const stage of batch) { - if (stage.type === 'completed') { - es.close(); - eventSourceRef.current = null; - if (timeoutRef.current) clearTimeout(timeoutRef.current); - setState({ - kind: 'completed', - target, - startedAt, - completedAt: new Date(), - events: [...events], - }); - return; - } - events.push(stage); - } - setState({ - kind: 'running', - target, - startedAt, - events: [...events], - eventSource: es, - }); - } catch (e) { - console.error('Failed to parse delivery event:', e); - } - }); - - es.onopen = () => { - setState({ - kind: 'running', - target, - startedAt, - events: [], - eventSource: es, - }); - }; - - es.onerror = () => { - es.close(); - eventSourceRef.current = null; - if (timeoutRef.current) clearTimeout(timeoutRef.current); - setState((prev) => { - if (prev.kind === 'completed') return prev; - return { - kind: 'failed', - target, - events: prev.kind === 'running' ? prev.events : [], - error: t('deliveryTrace.connectionLost', 'Connection lost before trace completed.'), - }; - }); - }; - } catch (e) { - setState({ - kind: 'failed', - target, - events: [], - error: e instanceof Error ? e.message : t('deliveryTrace.failedToStart', 'Failed to start trace.'), - }); - } - }, [inputValue, t]); - - const cancelTrace = useCallback(() => { - if (eventSourceRef.current) { - eventSourceRef.current.close(); - eventSourceRef.current = null; - } - if (timeoutRef.current) clearTimeout(timeoutRef.current); - setState({ kind: 'idle' }); - }, []); - - const resetToIdle = useCallback(() => { - setState({ kind: 'idle' }); - }, []); - - const rawEvents = useMemo(() => (state.kind === 'idle' || state.kind === 'starting' ? [] : state.events), [state]); - const pairedEvents = useMemo(() => { - const result: DeliveryStage[] = []; - const pendingByPhase = new Map(); - - for (const evt of rawEvents) { - const pk = phaseKey(evt); - const severity = stageSeverity(evt); - - if (severity === 'pending') { - pendingByPhase.set(pk, result.length); - result.push(evt); - } else { - const pendingIdx = pendingByPhase.get(pk); - if (pendingIdx !== undefined) { - result[pendingIdx] = evt; - pendingByPhase.delete(pk); - } else { - result.push(evt); - } - } - } - return result; - }, [rawEvents]); + // Kept here, not in the form, so the form still has it after a trace. + const [input, setInput] = useState(searchParams.get('target') ?? ''); + const { state, start, reset } = useDeliveryTrace(); + const events = state.kind === 'idle' || state.kind === 'starting' ? null : state.events; + const rows = useMemo(() => (events ? pairStages(events) : []), [events]); if (state.kind === 'idle' || state.kind === 'starting') { - return ( -
-
-

{t('deliveryTrace.title', 'Delivery Trace')}

-

- {t( - 'deliveryTrace.subtitle', - 'Run a real outbound SMTP delivery attempt and watch every DNS lookup, TLS handshake, and SMTP command as it happens.', - )} -

-
- - -
- -
- { - setInputValue(e.target.value); - setInputError(null); - }} - onKeyDown={(e) => { - if (e.key === 'Enter') startTrace(); - }} - autoFocus - className="flex-1" - /> - -
- {inputError &&

{inputError}

} -
-
-
-
- ); + return ; } - const events = pairedEvents; - const target = state.target; - const isRunning = state.kind === 'running'; - const isCompleted = state.kind === 'completed'; - const isFailed = state.kind === 'failed'; - - const groups: { attempt: DeliveryStage | null; events: DeliveryStage[] }[] = []; - let currentGroup: { attempt: DeliveryStage | null; events: DeliveryStage[] } = { - attempt: null, - events: [], - }; - for (const evt of events) { - if (evt.type === 'deliveryAttemptStart') { - if (currentGroup.events.length > 0 || currentGroup.attempt) { - groups.push(currentGroup); - } - currentGroup = { attempt: evt, events: [] }; - } else { - currentGroup.events.push(evt); - } - } - if (currentGroup.events.length > 0 || currentGroup.attempt) { - groups.push(currentGroup); - } - - const attemptCount = events.filter((e) => e.type === 'deliveryAttemptStart').length; - const lastEvent = events[events.length - 1]; - const hasSuccessfulDelivery = lastEvent?.type === 'quitCompleted' || events.some((e) => e.type === 'rcptToSuccess'); + const { target } = state; + const { attempts, delivered } = outcome(rows); + const heading = { + running: () => t('deliveryTrace.tracingDelivery', 'Tracing delivery to {{target}}', { target }), + completed: () => t('deliveryTrace.traceCompletedFor', 'Trace completed for {{target}}', { target }), + failed: () => t('deliveryTrace.traceFailedFor', 'Trace failed for {{target}}', { target }), + }[state.kind](); return (
-

- {isRunning - ? t('deliveryTrace.tracingDelivery', 'Tracing delivery to {{target}}', { target }) - : isCompleted - ? t('deliveryTrace.traceCompletedFor', 'Trace completed for {{target}}', { target }) - : t('deliveryTrace.traceFailedFor', 'Trace failed for {{target}}', { target })} -

- {isRunning &&

{relTime}

} +

{heading}

+ {state.kind === 'running' && }
- {isRunning && ( - - )} - {(isCompleted || isFailed) && ( - - )} +
- {isCompleted && ( - + {state.kind === 'completed' && ( +
- {hasSuccessfulDelivery ? ( + {delivered ? ( ) : ( )} - {hasSuccessfulDelivery + {delivered ? t('deliveryTrace.deliverySuccessful', 'Delivery successful') : t('deliveryTrace.deliveryFailed', 'Delivery failed')} - {attemptCount === 1 - ? t('deliveryTrace.attemptCount_one', '{{count}} attempt', { count: attemptCount }) - : t('deliveryTrace.attemptCount_other', '{{count}} attempts', { count: attemptCount })} - {state.kind === 'completed' && ( - <> - {' '} - {t('deliveryTrace.inDuration', 'in {{elapsed}}', { - elapsed: formatElapsed(state.completedAt.getTime() - state.startedAt.getTime()), - })} - - )} + {attempts === 1 + ? t('deliveryTrace.attemptCount_one', '{{count}} attempt', { count: attempts }) + : t('deliveryTrace.attemptCount_other', '{{count}} attempts', { count: attempts })}{' '} + {t('deliveryTrace.inDuration', 'in {{elapsed}}', { + elapsed: formatElapsed(state.completedAt - state.startedAt), + })}
-
+ )} - {isFailed && ( - + {state.kind === 'failed' && ( +

{state.error}

-
+ )}
- {groups.map((group, gi) => ( + {byAttempt(rows).map((group, gi) => (
{group.attempt && (
- +
)}
- {group.events.map((evt, ei) => ( - + {group.events.map((stage, ei) => ( + ))}
))}
- {isRunning && ( + {state.kind === 'running' && (
- . - - . - - - . - + {[0, 0.2, 0.4].map((delay) => ( + + . + + ))}
{t('deliveryTrace.waiting', 'Waiting for more events')}
diff --git a/src/features/troubleshoot/stream.test.ts b/src/features/troubleshoot/stream.test.ts new file mode 100644 index 0000000..5889261 --- /dev/null +++ b/src/features/troubleshoot/stream.test.ts @@ -0,0 +1,138 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const apiFetch = vi.fn(); +vi.mock('@/services/api', () => ({ + apiFetch: (...args: unknown[]) => apiFetch(...args), + getApiBaseUrl: () => 'https://mail.example', +})); + +/** Stands in for the browser's EventSource, so a test can play the server. */ +class FakeSource { + static last: FakeSource | null = null; + url: string; + closed = false; + onopen: (() => void) | null = null; + onerror: (() => void) | null = null; + private listeners: ((e: { data: string }) => void)[] = []; + constructor(url: string) { + this.url = url; + FakeSource.last = this; + } + addEventListener(name: string, fn: (e: { data: string }) => void) { + if (name === 'event') this.listeners.push(fn); + } + close() { + this.closed = true; + } + send(data: unknown) { + for (const fn of this.listeners) fn({ data: typeof data === 'string' ? data : JSON.stringify(data) }); + } +} +vi.stubGlobal('EventSource', FakeSource); + +const { streamDeliveryTrace } = await import('./stream'); + +const handlers = () => ({ opened: vi.fn(), stages: vi.fn(), failed: vi.fn() }); +const settle = () => new Promise((r) => setTimeout(r, 0)); + +beforeEach(() => { + FakeSource.last = null; + apiFetch.mockReset().mockResolvedValue({ text: async () => 'tok/en' }); +}); +afterEach(() => vi.useRealTimers()); + +describe('streamDeliveryTrace', () => { + it('gets a token, then opens the live stream for the target', async () => { + const on = handlers(); + streamDeliveryTrace('john@example.org', on); + await settle(); + expect(apiFetch).toHaveBeenCalledWith('/api/token/delivery'); + expect(FakeSource.last!.url).toBe('https://mail.example/api/live/delivery/john%40example.org?token=tok%2Fen'); + FakeSource.last!.onopen!(); + expect(on.opened).toHaveBeenCalledWith(expect.any(Number)); + }); + + it('passes on each batch, and closes once the trace completes', async () => { + const on = handlers(); + streamDeliveryTrace('example.org', on); + await settle(); + const source = FakeSource.last!; + source.send([{ type: 'mxLookupStart', domain: 'example.org' }]); + expect(source.closed).toBe(false); + source.send([{ type: 'quitCompleted', elapsed: 1 }, { type: 'completed' }]); + expect(on.stages).toHaveBeenCalledTimes(2); + expect(on.stages).toHaveBeenLastCalledWith( + [{ type: 'quitCompleted', elapsed: 1 }, { type: 'completed' }], + expect.any(Number), + ); + expect(source.closed).toBe(true); + source.onerror!(); + expect(on.failed).not.toHaveBeenCalled(); + }); + + it('skips a batch it cannot read', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + const on = handlers(); + streamDeliveryTrace('example.org', on); + await settle(); + FakeSource.last!.send('{not json'); + expect(on.stages).not.toHaveBeenCalled(); + expect(on.failed).not.toHaveBeenCalled(); + }); + + it('fails as lost when the stream drops', async () => { + const on = handlers(); + streamDeliveryTrace('example.org', on); + await settle(); + FakeSource.last!.onerror!(); + expect(on.failed).toHaveBeenCalledWith({ reason: 'lost' }); + expect(FakeSource.last!.closed).toBe(true); + }); + + it('gives up after the timeout', async () => { + vi.useFakeTimers(); + const on = handlers(); + streamDeliveryTrace('example.org', on); + await vi.advanceTimersByTimeAsync(0); + await vi.advanceTimersByTimeAsync(120_000); + expect(on.failed).toHaveBeenCalledWith({ reason: 'timeout' }); + expect(FakeSource.last!.closed).toBe(true); + }); + + it('fails to start when there is no token, with the reason', async () => { + apiFetch.mockRejectedValueOnce(new Error('HTTP 403')); + const on = handlers(); + streamDeliveryTrace('example.org', on); + await settle(); + expect(on.failed).toHaveBeenCalledWith({ reason: 'start', message: 'HTTP 403' }); + expect(FakeSource.last).toBeNull(); + }); + + it('opens nothing when closed before the token arrives', async () => { + const on = handlers(); + const close = streamDeliveryTrace('example.org', on); + close(); + await settle(); + expect(FakeSource.last).toBeNull(); + expect(on.failed).not.toHaveBeenCalled(); + }); + + it('stops listening once closed', async () => { + const on = handlers(); + const close = streamDeliveryTrace('example.org', on); + await settle(); + close(); + const source = FakeSource.last!; + expect(source.closed).toBe(true); + source.onerror!(); + source.send([{ type: 'mxLookupStart', domain: 'x' }]); + expect(on.failed).not.toHaveBeenCalled(); + expect(on.stages).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/troubleshoot/stream.ts b/src/features/troubleshoot/stream.ts new file mode 100644 index 0000000..f9a950e --- /dev/null +++ b/src/features/troubleshoot/stream.ts @@ -0,0 +1,83 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { apiFetch, getApiBaseUrl } from '@/services/api'; +import type { DeliveryStage } from './types'; + +/** A trace that hasn't completed by then is given up on. */ +export const TRACE_TIMEOUT_MS = 120_000; + +export type StreamFailure = { reason: 'lost' } | { reason: 'timeout' } | { reason: 'start'; message: string | null }; + +export interface StreamEvents { + /** The stream is open; `startedAt` is when it was asked for. */ + opened: (startedAt: number) => void; + /** A batch of steps, as the server sends them; the last batch ends with `completed`. */ + stages: (stages: DeliveryStage[], at: number) => void; + failed: (failure: StreamFailure) => void; +} + +/** + * Runs a live delivery trace: a short-lived token from the API, then the + * server's event stream of steps (EventSource can't send headers, hence + * the token in the URL). Returns a function that stops it; after that, + * nothing more is reported. + */ +export function streamDeliveryTrace(target: string, on: StreamEvents, timeoutMs = TRACE_TIMEOUT_MS): () => void { + let source: EventSource | null = null; + let timer: ReturnType | undefined; + let closed = false; + + const close = () => { + closed = true; + source?.close(); + source = null; + clearTimeout(timer); + }; + const fail = (failure: StreamFailure) => { + close(); + on.failed(failure); + }; + + void (async () => { + let token: string; + try { + token = await (await apiFetch('/api/token/delivery')).text(); + } catch (e) { + if (!closed) fail({ reason: 'start', message: e instanceof Error ? e.message : null }); + return; + } + if (closed) return; + + const startedAt = Date.now(); + const es = new EventSource( + `${getApiBaseUrl()}/api/live/delivery/${encodeURIComponent(target)}?token=${encodeURIComponent(token)}`, + ); + source = es; + timer = setTimeout(() => fail({ reason: 'timeout' }), timeoutMs); + + es.onopen = () => { + if (!closed) on.opened(startedAt); + }; + es.onerror = () => { + if (!closed) fail({ reason: 'lost' }); + }; + es.addEventListener('event', (message) => { + if (closed) return; + let stages: DeliveryStage[]; + try { + stages = JSON.parse((message as MessageEvent).data) as DeliveryStage[]; + } catch (e) { + console.error('Failed to parse delivery event:', e); + return; + } + on.stages(stages, Date.now()); + if (stages.some((s) => s.type === 'completed')) close(); + }); + })(); + + return close; +} diff --git a/src/features/troubleshoot/trace.test.ts b/src/features/troubleshoot/trace.test.ts new file mode 100644 index 0000000..019d65e --- /dev/null +++ b/src/features/troubleshoot/trace.test.ts @@ -0,0 +1,223 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, expect, it } from 'vitest'; +import { + byAttempt, + formatElapsed, + outcome, + pairStages, + stageDetail, + stageLabel, + stageSeverity, + traceReducer, + type TraceState, +} from './trace'; +import type { DeliveryStage } from './types'; + +/** Fills in the English text, the way i18next does with a default value. */ +const t = (_key: string, fallback: string, options?: Record) => + fallback.replace(/\{\{(\w+)\}\}/g, (_, name: string) => String(options?.[name])); + +const s = (stage: DeliveryStage) => stage; + +describe('stageSeverity', () => { + it.each([ + [{ type: 'mxLookupStart', domain: 'a.org' }, 'pending'], + [{ type: 'mxLookupSuccess', mxs: [], elapsed: 1 }, 'ok'], + [{ type: 'mxLookupError', reason: 'x', elapsed: 1 }, 'error'], + [{ type: 'mtaStsNotFound', elapsed: 1 }, 'warn'], + [{ type: 'deliveryAttemptStart', hostname: 'mx.a.org' }, 'ok'], + [{ type: 'daneVerifySuccess' }, 'ok'], + [{ type: 'quitCompleted', elapsed: 1 }, 'ok'], + [{ type: 'completed' }, 'ok'], + ] as [DeliveryStage, string][])('%o is %s', (stage, severity) => { + expect(stageSeverity(stage)).toBe(severity); + }); +}); + +describe('pairStages', () => { + it('replaces each start with how that step ended, in place', () => { + const paired = pairStages([ + s({ type: 'mxLookupStart', domain: 'a.org' }), + s({ type: 'mtaStsFetchStart' }), + s({ type: 'mxLookupSuccess', mxs: [], elapsed: 3 }), + s({ type: 'mtaStsNotFound', elapsed: 4 }), + s({ type: 'tlsRptLookupStart' }), + ]); + expect(paired.map((e) => e.type)).toEqual(['mxLookupSuccess', 'mtaStsNotFound', 'tlsRptLookupStart']); + }); + + it('keeps an outcome with no start, and each attempt apart', () => { + const paired = pairStages([ + s({ type: 'deliveryAttemptStart', hostname: 'mx1' }), + s({ type: 'mtaStsVerifySuccess' }), + s({ type: 'connectionStart', remoteIp: '1' }), + s({ type: 'connectionError', reason: 'refused', elapsed: 5 }), + s({ type: 'deliveryAttemptStart', hostname: 'mx2' }), + s({ type: 'connectionStart', remoteIp: '2' }), + ]); + expect(paired.map((e) => e.type)).toEqual([ + 'deliveryAttemptStart', + 'mtaStsVerifySuccess', + 'connectionError', + 'deliveryAttemptStart', + 'connectionStart', + ]); + }); +}); + +describe('byAttempt', () => { + it('groups the steps under the attempt they belong to, with lookups first', () => { + const lookup = s({ type: 'mxLookupSuccess', mxs: [], elapsed: 1 }); + const a1 = s({ type: 'deliveryAttemptStart', hostname: 'mx1' }); + const c1 = s({ type: 'connectionError', reason: 'x', elapsed: 1 }); + const a2 = s({ type: 'deliveryAttemptStart', hostname: 'mx2' }); + expect(byAttempt([lookup, a1, c1, a2])).toEqual([ + { attempt: null, events: [lookup] }, + { attempt: a1, events: [c1] }, + { attempt: a2, events: [] }, + ]); + expect(byAttempt([])).toEqual([]); + }); +}); + +describe('outcome', () => { + it('counts attempts and calls it delivered once a recipient was accepted', () => { + expect( + outcome([ + s({ type: 'deliveryAttemptStart', hostname: 'mx1' }), + s({ type: 'rcptToSuccess', elapsed: 1 }), + s({ type: 'quitStart' }), + ]), + ).toEqual({ attempts: 1, delivered: true }); + expect(outcome([s({ type: 'quitCompleted', elapsed: 1 })])).toEqual({ attempts: 0, delivered: true }); + expect( + outcome([ + s({ type: 'deliveryAttemptStart', hostname: 'mx1' }), + s({ type: 'deliveryAttemptStart', hostname: 'mx2' }), + s({ type: 'rcptToError', reason: 'no', elapsed: 1 }), + ]), + ).toEqual({ attempts: 2, delivered: false }); + }); +}); + +describe('labels', () => { + it('names each step', () => { + expect(stageLabel(t, s({ type: 'mxLookupStart', domain: 'a.org' }))).toBe('MX Lookup'); + expect(stageLabel(t, s({ type: 'mtaStsNotFound', elapsed: 1 }))).toBe('MTA-STS Policy Fetch'); + expect(stageLabel(t, s({ type: 'tlsaNotFound', elapsed: 1, reason: '' }))).toBe('TLSA / DANE Lookup'); + expect(stageLabel(t, s({ type: 'ehloSuccess', elapsed: 1 }))).toBe('EHLO'); + expect(stageLabel(t, s({ type: 'rcptToStart' }))).toBe('RCPT TO'); + expect(stageLabel(t, s({ type: 'deliveryAttemptStart', hostname: 'mx.a.org' }))).toBe('Delivery Attempt: mx.a.org'); + expect(stageLabel(t, s({ type: 'completed' }))).toBe('completed'); + }); + + it('describes what happened', () => { + const mxs = [ + { exchanges: ['a', 'b'], preference: 10 }, + { exchanges: ['c'], preference: 20 }, + ]; + expect(stageDetail(t, s({ type: 'mxLookupSuccess', mxs, elapsed: 1 }))).toBe('Resolved 3 MX records'); + expect(stageDetail(t, s({ type: 'mxLookupSuccess', mxs: [mxs[1]], elapsed: 1 }))).toBe('Resolved 1 MX record'); + expect(stageDetail(t, s({ type: 'mxLookupStart', domain: 'a.org' }))).toBe('Looking up MX for a.org'); + expect(stageDetail(t, s({ type: 'tlsRptLookupSuccess', rua: [], elapsed: 1 }))).toBe('Found 0 reporting URIs'); + expect(stageDetail(t, s({ type: 'connectionStart', remoteIp: '192.0.2.1' }))).toBe('Connecting to 192.0.2.1'); + expect(stageDetail(t, s({ type: 'quitCompleted', elapsed: 1 }))).toBe('Session closed'); + }); + + it('lists up to three addresses, then counts the rest', () => { + const ips = (n: number) => + ({ type: 'ipLookupSuccess', remoteIps: ['1', '2', '3', '4', '5'].slice(0, n), elapsed: 1 }) as const; + expect(stageDetail(t, ips(3))).toBe('1, 2, 3'); + expect(stageDetail(t, ips(5))).toBe('1, 2, 3 +2 more'); + }); + + it("shows the server's reason for errors, and for a missing TLSA record when it gives one", () => { + expect(stageDetail(t, s({ type: 'ehloError', reason: '421 busy', elapsed: 1 }))).toBe('421 busy'); + expect(stageDetail(t, s({ type: 'tlsaNotFound', reason: 'NXDOMAIN', elapsed: 1 }))).toBe('NXDOMAIN'); + expect(stageDetail(t, s({ type: 'tlsaNotFound', reason: '', elapsed: 1 }))).toBe('No TLSA record found'); + }); + + it('has nothing to add for some steps', () => { + expect(stageDetail(t, s({ type: 'mtaStsFetchStart' }))).toBeNull(); + expect(stageDetail(t, s({ type: 'deliveryAttemptStart', hostname: 'mx' }))).toBeNull(); + }); + + it('shows milliseconds under a second, then seconds', () => { + expect(formatElapsed(42.4)).toBe('42 ms'); + expect(formatElapsed(999)).toBe('999 ms'); + expect(formatElapsed(1000)).toBe('1.0 s'); + expect(formatElapsed(12_345)).toBe('12.3 s'); + }); +}); + +describe('traceReducer', () => { + const idle: TraceState = { kind: 'idle' }; + const mx = s({ type: 'mxLookupStart', domain: 'a.org' }); + const ok = s({ type: 'mxLookupSuccess', mxs: [], elapsed: 1 }); + + const running = (): TraceState => + [ + { type: 'start', target: 'a.org' }, + { type: 'opened', at: 1000 }, + ].reduce(traceReducer as never, idle); + + it('starts, then runs once the stream opens', () => { + const starting = traceReducer(idle, { type: 'start', target: 'a.org' }); + expect(starting).toEqual({ kind: 'starting', target: 'a.org' }); + expect(traceReducer(starting, { type: 'opened', at: 1000 })).toEqual({ + kind: 'running', + target: 'a.org', + startedAt: 1000, + events: [], + }); + }); + + it('adds each batch of steps', () => { + let state = traceReducer(running(), { type: 'stages', stages: [mx], at: 2000 }); + state = traceReducer(state, { type: 'stages', stages: [ok], at: 3000 }); + expect(state).toMatchObject({ kind: 'running', events: [mx, ok] }); + }); + + it('completes at the completed step, keeping what came before it in the batch', () => { + const state = traceReducer(running(), { + type: 'stages', + stages: [mx, { type: 'completed' }, ok], + at: 5000, + }); + expect(state).toEqual({ kind: 'completed', target: 'a.org', startedAt: 1000, completedAt: 5000, events: [mx] }); + }); + + it('fails when the stream drops, keeping the steps so far, but not after completing', () => { + const withSteps = traceReducer(running(), { type: 'stages', stages: [mx], at: 2000 }); + expect(traceReducer(withSteps, { type: 'failed', error: 'lost' })).toEqual({ + kind: 'failed', + target: 'a.org', + events: [mx], + error: 'lost', + }); + const starting = traceReducer(idle, { type: 'start', target: 'a.org' }); + expect(traceReducer(starting, { type: 'failed', error: 'no token' })).toEqual({ + kind: 'failed', + target: 'a.org', + events: [], + error: 'no token', + }); + const done = traceReducer(running(), { type: 'stages', stages: [{ type: 'completed' }], at: 2000 }); + expect(traceReducer(done, { type: 'failed', error: 'lost' })).toBe(done); + }); + + it('goes back to the form on reset, from any state', () => { + expect(traceReducer(running(), { type: 'reset' })).toEqual(idle); + }); + + it('ignores stream events once the trace is no longer live', () => { + expect(traceReducer(idle, { type: 'opened', at: 1 })).toBe(idle); + expect(traceReducer(idle, { type: 'stages', stages: [mx], at: 1 })).toBe(idle); + expect(traceReducer(idle, { type: 'failed', error: 'late' })).toBe(idle); + }); +}); diff --git a/src/features/troubleshoot/trace.ts b/src/features/troubleshoot/trace.ts new file mode 100644 index 0000000..2cd7ab7 --- /dev/null +++ b/src/features/troubleshoot/trace.ts @@ -0,0 +1,224 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * The delivery trace without the page: how the server's stream of steps + * becomes rows, attempts, labels and an outcome, and the states a trace + * goes through. + */ +import type { DeliveryStage } from './types'; + +export type StageSeverity = 'pending' | 'ok' | 'warn' | 'error'; + +/** `t` with an English default, as the console calls it. */ +type Translate = (key: string, fallback: string, options?: Record) => string; + +/** A step that has started and not finished is pending; the server names the rest by how they ended. */ +export function stageSeverity({ type }: DeliveryStage): StageSeverity { + if (type === 'completed' || type === 'deliveryAttemptStart') return 'ok'; + if (type.endsWith('Error')) return 'error'; + if (type.endsWith('NotFound')) return 'warn'; + if (type.endsWith('Start')) return 'pending'; + return 'ok'; +} + +/** The steps, by the prefix their stage names share, and how each is named. SMTP commands keep their own names. */ +const STEPS: [prefix: string, name: (t: Translate) => string][] = [ + ['mxLookup', (t) => t('deliveryTrace.phase.mxLookup', 'MX Lookup')], + ['mtaStsFetch', (t) => t('deliveryTrace.phase.mtaStsFetch', 'MTA-STS Policy Fetch')], + ['tlsRptLookup', (t) => t('deliveryTrace.phase.tlsRptLookup', 'TLS-RPT Lookup')], + ['mtaStsVerify', (t) => t('deliveryTrace.phase.mtaStsVerify', 'MTA-STS Verify')], + ['tlsaLookup', (t) => t('deliveryTrace.phase.tlsaLookup', 'TLSA / DANE Lookup')], + ['ipLookup', (t) => t('deliveryTrace.phase.ipLookup', 'IP Lookup')], + ['connection', (t) => t('deliveryTrace.phase.connection', 'TCP Connection')], + ['readGreeting', (t) => t('deliveryTrace.phase.readGreeting', 'Read Greeting')], + ['ehlo', () => 'EHLO'], + ['startTls', () => 'STARTTLS'], + ['daneVerify', (t) => t('deliveryTrace.phase.daneVerify', 'DANE Verify')], + ['mailFrom', () => 'MAIL FROM'], + ['rcptTo', () => 'RCPT TO'], + ['quit', () => 'QUIT'], +]; + +/** The three lookups that can find nothing name that outcome after the step. */ +const NOT_FOUND: Record = { + mtaStsNotFound: 'mtaStsFetch', + tlsRptNotFound: 'tlsRptLookup', + tlsaNotFound: 'tlsaLookup', +}; + +function stepOf(stage: DeliveryStage) { + const prefix = NOT_FOUND[stage.type] ?? stage.type; + return STEPS.find(([p]) => prefix.startsWith(p)); +} + +/** Which step a stage belongs to, so its outcome can take the place of its start. */ +function stepKey(stage: DeliveryStage): string { + if (stage.type === 'deliveryAttemptStart') return `attempt-${stage.hostname}`; + return stepOf(stage)?.[0] ?? stage.type; +} + +/** + * One row per step: a step's outcome replaces its start where the start + * was, so a row shows a spinner until the step ends, then how it went. + */ +export function pairStages(stages: DeliveryStage[]): DeliveryStage[] { + const rows: DeliveryStage[] = []; + const open = new Map(); + for (const stage of stages) { + const key = stepKey(stage); + const at = open.get(key); + if (stageSeverity(stage) === 'pending') { + open.set(key, rows.length); + rows.push(stage); + } else if (at !== undefined) { + rows[at] = stage; + open.delete(key); + } else { + rows.push(stage); + } + } + return rows; +} + +export interface AttemptGroup { + /** The attempt's own row, or null for the lookups before the first attempt. */ + attempt: DeliveryStage | null; + events: DeliveryStage[]; +} + +/** The rows split by delivery attempt, each attempt with the steps it ran. */ +export function byAttempt(rows: DeliveryStage[]): AttemptGroup[] { + const groups: AttemptGroup[] = []; + for (const row of rows) { + if (row.type === 'deliveryAttemptStart') groups.push({ attempt: row, events: [] }); + else if (groups.length === 0) groups.push({ attempt: null, events: [row] }); + else groups[groups.length - 1].events.push(row); + } + return groups; +} + +/** How many hosts were tried, and whether one took the message. */ +export function outcome(rows: DeliveryStage[]) { + return { + attempts: rows.filter((r) => r.type === 'deliveryAttemptStart').length, + delivered: rows.at(-1)?.type === 'quitCompleted' || rows.some((r) => r.type === 'rcptToSuccess'), + }; +} + +export function stageLabel(t: Translate, stage: DeliveryStage): string { + if (stage.type === 'deliveryAttemptStart') { + return t('deliveryTrace.attemptLabel', 'Delivery Attempt: {{hostname}}', { hostname: stage.hostname }); + } + const step = stepOf(stage); + return step ? step[1](t) : stage.type; +} + +/** What a step that ended well found or did. */ +const DONE: Partial string>> = { + mtaStsFetchSuccess: (t) => t('deliveryTrace.body.mtaStsFetchSuccess', 'Policy fetched successfully'), + mtaStsNotFound: (t) => t('deliveryTrace.body.mtaStsNotFound', 'No MTA-STS policy published'), + tlsRptNotFound: (t) => t('deliveryTrace.body.tlsRptNotFound', 'No TLS-RPT record published'), + connectionSuccess: (t) => t('deliveryTrace.body.connectionSuccess', 'Connected'), + tlsaLookupSuccess: (t) => t('deliveryTrace.body.tlsaLookupSuccess', 'TLSA record found'), + readGreetingSuccess: (t) => t('deliveryTrace.body.readGreetingSuccess', 'Server greeting received'), + ehloSuccess: (t) => t('deliveryTrace.body.ehloSuccess', 'EHLO accepted'), + startTlsSuccess: (t) => t('deliveryTrace.body.startTlsSuccess', 'TLS negotiated'), + daneVerifySuccess: (t) => t('deliveryTrace.body.daneVerifySuccess', 'DANE verification passed'), + mtaStsVerifySuccess: (t) => t('deliveryTrace.body.mtaStsVerifySuccess', 'MTA-STS hostname verified'), + mailFromSuccess: (t) => t('deliveryTrace.body.mailFromSuccess', 'Sender accepted'), + rcptToSuccess: (t) => t('deliveryTrace.body.rcptToSuccess', 'Recipient accepted'), + quitCompleted: (t) => t('deliveryTrace.body.quitCompleted', 'Session closed'), +}; + +/** The line under a step's name: what it found, or why it failed; null for steps with nothing to add. */ +export function stageDetail(t: Translate, stage: DeliveryStage): string | null { + switch (stage.type) { + case 'mxLookupStart': + return t('deliveryTrace.body.mxLookupStart', 'Looking up MX for {{domain}}', { domain: stage.domain }); + case 'mxLookupSuccess': { + const hosts = stage.mxs.reduce((n, mx) => n + mx.exchanges.length, 0); + return hosts === 1 + ? t('deliveryTrace.body.mxLookupSuccess_one', 'Resolved {{count}} MX record', { count: hosts }) + : t('deliveryTrace.body.mxLookupSuccess_other', 'Resolved {{count}} MX records', { count: hosts }); + } + case 'tlsRptLookupSuccess': { + const count = stage.rua.length; + return count === 1 + ? t('deliveryTrace.body.tlsRptLookupSuccess_one', 'Found {{count}} reporting URI', { count }) + : t('deliveryTrace.body.tlsRptLookupSuccess_other', 'Found {{count}} reporting URIs', { count }); + } + case 'ipLookupSuccess': { + const ips = stage.remoteIps; + if (ips.length <= 3) return ips.join(', '); + return t('deliveryTrace.body.ipLookupOverflow', '{{shown}} +{{extra}} more', { + shown: ips.slice(0, 3).join(', '), + extra: ips.length - 3, + }); + } + case 'connectionStart': + return t('deliveryTrace.body.connectionStart', 'Connecting to {{ip}}', { ip: stage.remoteIp }); + case 'tlsaNotFound': + return stage.reason || t('deliveryTrace.body.tlsaNotFound', 'No TLSA record found'); + } + const done = DONE[stage.type]; + if (done) return done(t); + return 'reason' in stage && typeof stage.reason === 'string' ? stage.reason : null; +} + +export function formatElapsed(ms: number): string { + return ms < 1000 ? `${Math.round(ms)} ms` : `${(ms / 1000).toFixed(1)} s`; +} + +export type TraceState = + | { kind: 'idle' } + | { kind: 'starting'; target: string } + | { kind: 'running'; target: string; startedAt: number; events: DeliveryStage[] } + | { kind: 'completed'; target: string; startedAt: number; completedAt: number; events: DeliveryStage[] } + | { kind: 'failed'; target: string; events: DeliveryStage[]; error: string }; + +export type TraceAction = + | { type: 'start'; target: string } + /** The stream is open; `at` is when the trace began. */ + | { type: 'opened'; at: number } + | { type: 'stages'; stages: DeliveryStage[]; at: number } + | { type: 'failed'; error: string } + | { type: 'reset' }; + +/** A trace's life: the form, starting, streaming steps, then completed or failed. */ +export function traceReducer(state: TraceState, action: TraceAction): TraceState { + switch (action.type) { + case 'start': + return { kind: 'starting', target: action.target }; + case 'reset': + return { kind: 'idle' }; + case 'opened': + return state.kind === 'starting' + ? { kind: 'running', target: state.target, startedAt: action.at, events: [] } + : state; + case 'stages': { + if (state.kind !== 'running' && state.kind !== 'starting') return state; + const startedAt = state.kind === 'running' ? state.startedAt : action.at; + const events = state.kind === 'running' ? [...state.events] : []; + for (const stage of action.stages) { + // The server ends the stream with `completed`; anything after it is ignored. + if (stage.type === 'completed') { + return { kind: 'completed', target: state.target, startedAt, completedAt: action.at, events }; + } + events.push(stage); + } + return { kind: 'running', target: state.target, startedAt, events }; + } + case 'failed': + if (state.kind !== 'running' && state.kind !== 'starting') return state; + return { + kind: 'failed', + target: state.target, + events: state.kind === 'running' ? state.events : [], + error: action.error, + }; + } +} diff --git a/src/features/troubleshoot/types.ts b/src/features/troubleshoot/types.ts index 2e5ba2f..1f1a928 100644 --- a/src/features/troubleshoot/types.ts +++ b/src/features/troubleshoot/types.ts @@ -1,10 +1,16 @@ /* - * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC + * SPDX-FileCopyrightText: 2026 Coffey Labs LLC * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + * SPDX-License-Identifier: AGPL-3.0-only */ +/** + * The steps the server's live delivery stream reports (`/api/live/delivery`), + * by their names on the wire. Each step starts, then ends in Success, Error + * or, for the optional lookups, NotFound; `elapsed` is in milliseconds. + */ export type DeliveryStage = + // Lookups for the recipient's domain. | { type: 'mxLookupStart'; domain: string } | { type: 'mxLookupSuccess'; mxs: MX[]; elapsed: number } | { type: 'mxLookupError'; reason: string; elapsed: number } @@ -16,6 +22,7 @@ export type DeliveryStage = | { type: 'tlsRptLookupSuccess'; rua: ReportUri[]; elapsed: number } | { type: 'tlsRptLookupError'; reason: string; elapsed: number } | { type: 'tlsRptNotFound'; elapsed: number } + // Then, per MX host tried: | { type: 'deliveryAttemptStart'; hostname: string } | { type: 'mtaStsVerifySuccess' } | { type: 'mtaStsVerifyError'; reason: string } @@ -48,26 +55,18 @@ export type DeliveryStage = | { type: 'rcptToError'; reason: string; elapsed: number } | { type: 'quitStart' } | { type: 'quitCompleted'; elapsed: number } + // The end of the stream. | { type: 'completed' }; +/** MX hosts that share a preference. */ export interface MX { exchanges: string[]; preference: number; } +/** Where a domain wants TLS reports sent (RFC 8460). */ export type ReportUri = { type: 'mail'; email: string } | { type: 'http'; url: string }; +/** The MTA-STS policy and the TLSA record, shown as the server sends them. */ export type Policy = Record; export type Tlsa = Record; - -export type StageSeverity = 'pending' | 'ok' | 'warn' | 'error'; - -export function stageSeverity(stage: DeliveryStage): StageSeverity { - const t = stage.type; - if (t === 'completed') return 'ok'; - if (t === 'deliveryAttemptStart') return 'ok'; - if (t.endsWith('Error')) return 'error'; - if (t.endsWith('NotFound')) return 'warn'; - if (t.endsWith('Start')) return 'pending'; - return 'ok'; -} -- 2.54.0