Rewrite the delivery trace page #126
No files matched your search
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
Reference in new issue
Block a user