Rewrite the delivery trace page #126

Merged
jcoffey-dev merged 1 commits from phase4/trace into main 2026-10-19 04:17:36 +00:00
6 changed files with 962 additions and 553 deletions

No files matched your search

+281 -539
View File
@@ -1,253 +1,160 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* 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 (
<div
className={`rounded-lg border p-4 ${
variant === 'destructive' ? 'border-destructive/50 bg-destructive/10 text-destructive' : 'border-border bg-card'
}`}
>
{children}
</div>
);
}
/** 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, unknown>) => 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 <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />;
case 'ok':
return <CircleCheck className="h-4 w-4 text-emerald-500" />;
case 'warn':
return <TriangleAlert className="h-4 w-4 text-amber-500" />;
case 'error':
return <CircleX className="h-4 w-4 text-red-500" />;
}
}
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 <div className={`rounded-lg border p-4 ${look}`}>{children}</div>;
}
const SEVERITY_ICON: Record<StageSeverity, ReactNode> = {
pending: <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />,
ok: <CircleCheck className="h-4 w-4 text-emerald-500" />,
warn: <TriangleAlert className="h-4 w-4 text-amber-500" />,
error: <CircleX className="h-4 w-4 text-red-500" />,
};
/** 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 (
<div className="mt-1 rounded border text-xs">
{sorted.map((mx, i) =>
mx.exchanges.map((ex, j) => (
<div key={`${i}-${j}`} className="flex gap-3 px-3 py-1 border-b last:border-b-0">
<span className="w-8 text-right text-muted-foreground">{mx.preference}</span>
<span>{ex}</span>
</div>
)),
)}
</div>
);
}
function ReportUris({ rua }: { rua: ReportUri[] }) {
return (
<div className="mt-1 space-y-1">
{rua.map((uri, i) => (
<div key={i} className="flex items-center gap-2 text-xs">
{uri.type === 'mail' ? (
<>
<Mail className="h-3 w-3 text-muted-foreground" />
<span>{uri.email}</span>
</>
) : (
<>
<Globe className="h-3 w-3 text-muted-foreground" />
<span>{uri.url}</span>
</>
)}
{rows.map((row, i) => (
<div key={i} className="flex gap-3 px-3 py-1 border-b last:border-b-0">
<span className="w-8 text-right text-muted-foreground">{row.preference}</span>
<span>{row.host}</span>
</div>
))}
</div>
);
}
function OpaqueObject({ data }: { data: Record<string, unknown> }) {
function ReportDestinations({ rua }: { rua: ReportUri[] }) {
return (
<div className="mt-1 space-y-1">
{rua.map((uri, i) => (
<div key={i} className="flex items-center gap-2 text-xs">
{uri.type === 'mail' ? (
<Mail className="h-3 w-3 text-muted-foreground" />
) : (
<Globe className="h-3 w-3 text-muted-foreground" />
)}
<span>{uri.type === 'mail' ? uri.email : uri.url}</span>
</div>
))}
</div>
);
}
/** A policy or record as the server sent it: one line per field, nested values as JSON. */
function RawFields({ data }: { data: Record<string, unknown> }) {
return (
<div className="mt-1 rounded border bg-muted/30 p-2">
<dl className="space-y-1 text-xs">
{Object.entries(data).map(([k, v]) => (
<div key={k} className="flex gap-2">
<dt className="font-medium text-muted-foreground min-w-24">{k}</dt>
{Object.entries(data).map(([name, value]) => (
<div key={name} className="flex gap-2">
<dt className="font-medium text-muted-foreground min-w-24">{name}</dt>
<dd className="break-all">
{typeof v === 'object' && v !== null ? (
<pre className="text-xs whitespace-pre-wrap">{JSON.stringify(v, null, 2)}</pre>
{value !== null && typeof value === 'object' ? (
<pre className="text-xs whitespace-pre-wrap">{JSON.stringify(value, null, 2)}</pre>
) : (
String(v)
String(value)
)}
</dd>
</div>
@@ -257,409 +164,244 @@ function OpaqueObject({ data }: { data: Record<string, unknown> }) {
);
}
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 <MxHosts mxs={stage.mxs} />;
case 'tlsRptLookupSuccess':
return <ReportDestinations rua={stage.rua} />;
case 'mtaStsFetchSuccess':
return <RawFields data={stage.policy} />;
case 'tlsaLookupSuccess':
return <RawFields data={stage.record} />;
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 = (
<div className="flex items-start gap-3 py-2">
<div className="mt-0.5 shrink-0">
<SeverityIcon severity={severity} />
</div>
<div className="mt-0.5 shrink-0">{SEVERITY_ICON[severity]}</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{phaseLabel(t, stage)}</span>
{elapsed !== null && (
<span className="text-sm font-medium">{stageLabel(t, stage)}</span>
{'elapsed' in stage && (
<Badge variant="secondary" className="text-xs">
{formatElapsed(elapsed)}
{formatElapsed(stage.elapsed)}
</Badge>
)}
</div>
{body && (
<p className={`text-xs mt-0.5 ${severity === 'error' ? 'text-red-500' : 'text-muted-foreground'}`}>{body}</p>
{detail && (
<p className={`text-xs mt-0.5 ${severity === 'error' ? 'text-red-500' : 'text-muted-foreground'}`}>
{detail}
</p>
)}
</div>
</div>
);
if (!hasDetails) return content;
if (!details) return row;
return (
<Collapsible>
{content}
{row}
<CollapsibleTrigger asChild>
<button className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground ml-7">
<ChevronRight className="h-3 w-3" />
{t('deliveryTrace.viewDetails', 'View details')}
</button>
</CollapsibleTrigger>
<CollapsibleContent className="ml-7">
{stage.type === 'mxLookupSuccess' && <MxTable mxs={stage.mxs} />}
{stage.type === 'mtaStsFetchSuccess' && <OpaqueObject data={stage.policy} />}
{stage.type === 'tlsRptLookupSuccess' && <ReportUris rua={stage.rua} />}
{stage.type === 'tlsaLookupSuccess' && <OpaqueObject data={stage.record} />}
</CollapsibleContent>
<CollapsibleContent className="ml-7">{details}</CollapsibleContent>
</Collapsible>
);
}
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 (
<div className="mx-auto max-w-2xl space-y-6 pt-8">
<div>
<h1 className="text-2xl font-bold">{t('deliveryTrace.title', 'Delivery Trace')}</h1>
<p className="mt-2 text-muted-foreground">
{t(
'deliveryTrace.subtitle',
'Run a real outbound SMTP delivery attempt and watch every DNS lookup, TLS handshake, and SMTP command as it happens.',
)}
</p>
</div>
<Card>
<CardContent className="p-4 space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">{t('deliveryTrace.targetLabel', 'Target address or domain')}</label>
<div className="flex gap-2">
<Input
placeholder={t('deliveryTrace.targetPlaceholder', '[email protected] or example.org')}
value={target}
onChange={(e) => {
onTargetChange(e.target.value);
setInvalid(false);
}}
onKeyDown={(e) => e.key === 'Enter' && submit()}
autoFocus
className="flex-1"
/>
<Button onClick={submit} disabled={starting}>
{starting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Play className="h-4 w-4" />}
<span className="ml-2">{t('deliveryTrace.startTrace', 'Start trace')}</span>
</Button>
</div>
{invalid && (
<p className="text-xs text-destructive">
{t('deliveryTrace.invalidTarget', 'Enter a valid email address or domain.')}
</p>
)}
</div>
</CardContent>
</Card>
</div>
);
}
function StartedAgo({ since }: { since: number }) {
const { t } = useTranslation();
const seconds = useSecondsSince(since);
return (
<p className="text-sm text-muted-foreground">
{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,
})}
</p>
);
}
export function DeliveryTracePage() {
const { t } = useTranslation();
const [searchParams] = useSearchParams();
const [inputValue, setInputValue] = useState(searchParams.get('target') ?? '');
const [inputError, setInputError] = useState<string | null>(null);
const [state, setState] = useState<TraceState>({ kind: 'idle' });
const eventSourceRef = useRef<EventSource | null>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | 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<string, number>();
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 (
<div className="mx-auto max-w-2xl space-y-6 pt-8">
<div>
<h1 className="text-2xl font-bold">{t('deliveryTrace.title', 'Delivery Trace')}</h1>
<p className="mt-2 text-muted-foreground">
{t(
'deliveryTrace.subtitle',
'Run a real outbound SMTP delivery attempt and watch every DNS lookup, TLS handshake, and SMTP command as it happens.',
)}
</p>
</div>
<Card>
<CardContent className="p-4 space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium">
{t('deliveryTrace.targetLabel', 'Target address or domain')}
</label>
<div className="flex gap-2">
<Input
placeholder={t('deliveryTrace.targetPlaceholder', '[email protected] or example.org')}
value={inputValue}
onChange={(e) => {
setInputValue(e.target.value);
setInputError(null);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') startTrace();
}}
autoFocus
className="flex-1"
/>
<Button onClick={startTrace} disabled={state.kind === 'starting'}>
{state.kind === 'starting' ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Play className="h-4 w-4" />
)}
<span className="ml-2">{t('deliveryTrace.startTrace', 'Start trace')}</span>
</Button>
</div>
{inputError && <p className="text-xs text-destructive">{inputError}</p>}
</div>
</CardContent>
</Card>
</div>
);
return <TraceForm target={input} onTargetChange={setInput} starting={state.kind === 'starting'} onStart={start} />;
}
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 (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold">
{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 })}
</h2>
{isRunning && <p className="text-sm text-muted-foreground">{relTime}</p>}
<h2 className="text-lg font-semibold">{heading}</h2>
{state.kind === 'running' && <StartedAgo since={state.startedAt} />}
</div>
<div className="flex gap-2">
{isRunning && (
<Button variant="outline" onClick={cancelTrace}>
<StopCircle className="mr-2 h-4 w-4" />
{t('deliveryTrace.cancelTrace', 'Cancel trace')}
</Button>
)}
{(isCompleted || isFailed) && (
<Button variant="outline" onClick={resetToIdle}>
{t('deliveryTrace.runAnother', 'Run another trace')}
</Button>
)}
<Button variant="outline" onClick={reset}>
{state.kind === 'running' ? (
<>
<StopCircle className="mr-2 h-4 w-4" />
{t('deliveryTrace.cancelTrace', 'Cancel trace')}
</>
) : (
t('deliveryTrace.runAnother', 'Run another trace')
)}
</Button>
</div>
</div>
{isCompleted && (
<SimpleAlert variant="default">
{state.kind === 'completed' && (
<Notice tone="default">
<div className="flex items-center gap-2">
{hasSuccessfulDelivery ? (
{delivered ? (
<CircleCheck className="h-4 w-4 text-emerald-500" />
) : (
<CircleX className="h-4 w-4 text-red-500" />
)}
<span className="text-sm font-medium">
{hasSuccessfulDelivery
{delivered
? t('deliveryTrace.deliverySuccessful', 'Delivery successful')
: t('deliveryTrace.deliveryFailed', 'Delivery failed')}
</span>
<span className="text-sm text-muted-foreground">
{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),
})}
</span>
</div>
</SimpleAlert>
</Notice>
)}
{isFailed && (
<SimpleAlert variant="destructive">
{state.kind === 'failed' && (
<Notice tone="destructive">
<p className="text-sm">{state.error}</p>
</SimpleAlert>
</Notice>
)}
<Card>
<CardContent className="p-4">
<div className="space-y-0">
{groups.map((group, gi) => (
{byAttempt(rows).map((group, gi) => (
<div key={gi}>
{group.attempt && (
<div className="mt-3 mb-1">
<EventRow stage={group.attempt} />
<StepRow stage={group.attempt} />
</div>
)}
<div className={group.attempt ? 'ml-6 border-l-2 border-muted/40 pl-4' : ''}>
{group.events.map((evt, ei) => (
<EventRow key={`${gi}-${ei}`} stage={evt} />
{group.events.map((stage, ei) => (
<StepRow key={`${gi}-${ei}`} stage={stage} />
))}
</div>
</div>
))}
</div>
{isRunning && (
{state.kind === 'running' && (
<div className="mt-4 flex items-center gap-2 text-sm text-muted-foreground">
<div className="flex gap-1">
<span className="animate-pulse">.</span>
<span className="animate-pulse" style={{ animationDelay: '0.2s' }}>
.
</span>
<span className="animate-pulse" style={{ animationDelay: '0.4s' }}>
.
</span>
{[0, 0.2, 0.4].map((delay) => (
<span
key={delay}
className="animate-pulse"
style={delay ? { animationDelay: `${delay}s` } : undefined}
>
.
</span>
))}
</div>
{t('deliveryTrace.waiting', 'Waiting for more events')}
</div>
+138
View File
@@ -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('[email protected]', 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();
});
});
+83
View File
@@ -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<typeof setTimeout> | 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<string>).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;
}
+223
View File
@@ -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<string, unknown>) =>
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);
});
});
+224
View File
@@ -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, unknown>) => 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<string, string> = {
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<string, number>();
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<Record<DeliveryStage['type'], (t: Translate) => 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,
};
}
}
+13 -14
View File
@@ -1,10 +1,16 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
* 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<string, unknown>;
export type Tlsa = Record<string, unknown>;
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';
}