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'; -}