Received reports: read the report lists as the server sends them #89
No files matched your search
@@ -216,7 +216,14 @@ export function DmarcSummaryCard() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const summaries = useMemo(() => (reports ? summarizeDmarc(reports) : []), [reports]);
|
||||
// A report shaped in a way we don't expect shows as an error here, not a broken page.
|
||||
const [summaries, summaryError] = useMemo(() => {
|
||||
try {
|
||||
return [reports ? summarizeDmarc(reports) : [], null] as const;
|
||||
} catch (e) {
|
||||
return [[], e instanceof Error ? e.message : String(e)] as const;
|
||||
}
|
||||
}, [reports]);
|
||||
|
||||
return (
|
||||
<section className="space-y-3 rounded-xl border p-4">
|
||||
@@ -232,8 +239,8 @@ export function DmarcSummaryCard() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{error ? (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
{error || summaryError ? (
|
||||
<p className="text-sm text-destructive">{error ?? summaryError}</p>
|
||||
) : !reports ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
) : summaries.length === 0 ? (
|
||||
|
||||
@@ -70,7 +70,14 @@ export function TlsSummaryCard() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
const summaries = useMemo(() => (reports ? summarizeTls(reports) : []), [reports]);
|
||||
// A report shaped in a way we don't expect shows as an error here, not a broken page.
|
||||
const [summaries, summaryError] = useMemo(() => {
|
||||
try {
|
||||
return [reports ? summarizeTls(reports) : [], null] as const;
|
||||
} catch (e) {
|
||||
return [[], e instanceof Error ? e.message : String(e)] as const;
|
||||
}
|
||||
}, [reports]);
|
||||
|
||||
return (
|
||||
<section className="space-y-3 rounded-xl border p-4">
|
||||
@@ -83,8 +90,8 @@ export function TlsSummaryCard() {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{error ? (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
{error || summaryError ? (
|
||||
<p className="text-sm text-destructive">{error ?? summaryError}</p>
|
||||
) : !reports ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
) : summaries.length === 0 ? (
|
||||
|
||||
@@ -16,6 +16,9 @@ import { getAccountId, jmapRequest } from '@/services/jmap/client';
|
||||
/** At most this many of each kind are read; the newest ones win. */
|
||||
export const REPORT_LIMIT = 500;
|
||||
|
||||
/** The server sends an objectList as a map keyed by index ("0", "1", …); read it with jmapMapToArray. */
|
||||
export type JmapList<T> = T[] | Record<string, T>;
|
||||
|
||||
export type DmarcResult = 'pass' | 'fail' | 'unspecified';
|
||||
export type Disposition = 'none' | 'pass' | 'quarantine' | 'reject' | 'unspecified';
|
||||
|
||||
@@ -27,8 +30,8 @@ export interface DmarcRecord {
|
||||
evaluatedDisposition: Disposition;
|
||||
evaluatedDkim: DmarcResult;
|
||||
evaluatedSpf: DmarcResult;
|
||||
dkimResults?: { domain: string; selector?: string; result: string }[];
|
||||
spfResults?: { domain: string; result: string }[];
|
||||
dkimResults?: JmapList<{ domain: string; selector?: string; result: string }>;
|
||||
spfResults?: JmapList<{ domain: string; result: string }>;
|
||||
}
|
||||
|
||||
export interface DmarcReport {
|
||||
@@ -38,7 +41,7 @@ export interface DmarcReport {
|
||||
policyDomain: string;
|
||||
policyDisposition?: string;
|
||||
policyTestingMode?: boolean;
|
||||
records: DmarcRecord[];
|
||||
records: JmapList<DmarcRecord>;
|
||||
}
|
||||
|
||||
export interface DmarcExternalReport {
|
||||
@@ -59,7 +62,7 @@ export interface TlsPolicy {
|
||||
policyType?: string;
|
||||
totalSuccessfulSessions: number;
|
||||
totalFailedSessions: number;
|
||||
failureDetails?: TlsFailure[];
|
||||
failureDetails?: JmapList<TlsFailure>;
|
||||
}
|
||||
|
||||
export interface TlsExternalReport {
|
||||
@@ -69,7 +72,7 @@ export interface TlsExternalReport {
|
||||
organizationName?: string | null;
|
||||
dateRangeStart: string;
|
||||
dateRangeEnd: string;
|
||||
policies: TlsPolicy[];
|
||||
policies: JmapList<TlsPolicy>;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { DmarcExternalReport, DmarcRecord, DmarcReport, TlsExternalReport, TlsPolicy } from './api';
|
||||
import type { DmarcExternalReport, DmarcRecord, DmarcReport, JmapList, TlsExternalReport, TlsPolicy } from './api';
|
||||
import { summarizeDmarc, summarizeTls } from './summarize';
|
||||
|
||||
function record(over: Partial<DmarcRecord> = {}): DmarcRecord {
|
||||
@@ -39,6 +39,23 @@ function dmarc(records: DmarcRecord[], over: Partial<DmarcReport> = {}, id = 'a'
|
||||
}
|
||||
|
||||
describe('summarizeDmarc', () => {
|
||||
it('reads lists the way the server sends them: maps keyed by index', () => {
|
||||
const wire = dmarc([]);
|
||||
wire.report.records = {
|
||||
'0': record({ dkimResults: { '0': { domain: 'example.org', result: 'pass' } }, spfResults: {} }),
|
||||
'1': record({
|
||||
sourceIp: '203.0.113.5',
|
||||
evaluatedDkim: 'fail',
|
||||
evaluatedSpf: 'fail',
|
||||
dkimResults: {},
|
||||
spfResults: {},
|
||||
}),
|
||||
};
|
||||
const [d] = summarizeDmarc([wire]);
|
||||
expect(d).toMatchObject({ messages: 20, passed: 10, failed: 10 });
|
||||
expect(d.sources.map((s) => s.label)).toEqual(['203.0.113.5', 'example.org']);
|
||||
});
|
||||
|
||||
it('counts passing mail under the domain that proved it', () => {
|
||||
const [d] = summarizeDmarc([
|
||||
dmarc([record(), record({ sourceIp: '192.0.2.11', count: 5 })]),
|
||||
@@ -102,7 +119,7 @@ describe('summarizeDmarc', () => {
|
||||
});
|
||||
});
|
||||
|
||||
function tls(policies: TlsPolicy[], org = 'google.com'): TlsExternalReport {
|
||||
function tls(policies: JmapList<TlsPolicy>, org = 'google.com'): TlsExternalReport {
|
||||
return {
|
||||
id: org,
|
||||
receivedAt: '2026-10-02T00:00:00Z',
|
||||
@@ -116,6 +133,21 @@ function tls(policies: TlsPolicy[], org = 'google.com'): TlsExternalReport {
|
||||
}
|
||||
|
||||
describe('summarizeTls', () => {
|
||||
it('reads policies and failures sent as maps keyed by index', () => {
|
||||
const [d] = summarizeTls([
|
||||
tls({
|
||||
'0': {
|
||||
policyDomain: 'example.org',
|
||||
totalSuccessfulSessions: 5,
|
||||
totalFailedSessions: 2,
|
||||
failureDetails: { '0': { resultType: 'certificateExpired', failedSessionCount: 2 } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
expect(d).toMatchObject({ domain: 'example.org', succeeded: 5, failed: 2 });
|
||||
expect(d.failures).toEqual([{ kind: 'certificateExpired', sessions: 2, hosts: [] }]);
|
||||
});
|
||||
|
||||
it('adds up sessions per domain and groups failures by kind', () => {
|
||||
const [d] = summarizeTls([
|
||||
tls([
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
* tested without a server.
|
||||
*/
|
||||
|
||||
import type { Disposition, DmarcExternalReport, DmarcRecord, TlsExternalReport } from './api';
|
||||
import { jmapMapToArray } from '@/lib/jmapUtils';
|
||||
import type { Disposition, DmarcExternalReport, DmarcRecord, TlsExternalReport, TlsFailure, TlsPolicy } from './api';
|
||||
|
||||
/** One server (or service) that sent mail with one of our domains in From. */
|
||||
export interface Source {
|
||||
@@ -52,9 +53,13 @@ function handling(d: Disposition): keyof Source['failedHandling'] {
|
||||
|
||||
/** Who the sender proved to be. A pass without a passing domain falls back to the IP. */
|
||||
function identify(r: DmarcRecord): Pick<Source, 'label' | 'provedBy'> {
|
||||
const dkim = r.dkimResults?.find((d) => d.result === 'pass' && d.domain);
|
||||
const dkim = jmapMapToArray<{ domain: string; result: string }>(r.dkimResults).find(
|
||||
(d) => d.result === 'pass' && d.domain,
|
||||
);
|
||||
if (dkim) return { label: dkim.domain.toLowerCase(), provedBy: 'dkim' };
|
||||
const spf = r.spfResults?.find((s) => s.result === 'pass' && s.domain);
|
||||
const spf = jmapMapToArray<{ domain: string; result: string }>(r.spfResults).find(
|
||||
(s) => s.result === 'pass' && s.domain,
|
||||
);
|
||||
if (spf) return { label: spf.domain.toLowerCase(), provedBy: 'spf' };
|
||||
return { label: r.sourceIp ?? '?', provedBy: null };
|
||||
}
|
||||
@@ -64,6 +69,7 @@ export function summarizeDmarc(reports: DmarcExternalReport[]): DomainSummary[]
|
||||
/** Per domain, the end of the newest report, whose policy wins. */
|
||||
const newest = new Map<string, string>();
|
||||
for (const { report } of reports) {
|
||||
if (!report?.policyDomain) continue;
|
||||
const name = report.policyDomain.toLowerCase();
|
||||
let d = domains.get(name);
|
||||
if (!d) {
|
||||
@@ -91,7 +97,7 @@ export function summarizeDmarc(reports: DmarcExternalReport[]): DomainSummary[]
|
||||
d.policy = p === 'none' || p === 'quarantine' || p === 'reject' ? p : 'unknown';
|
||||
d.testing = report.policyTestingMode === true;
|
||||
}
|
||||
for (const r of report.records ?? []) {
|
||||
for (const r of jmapMapToArray<DmarcRecord>(report.records)) {
|
||||
const count = r.count ?? 0;
|
||||
const ok = passes(r);
|
||||
const who = identify(r);
|
||||
@@ -173,8 +179,10 @@ const KINDS = new Set<string>([
|
||||
export function summarizeTls(reports: TlsExternalReport[]): TlsSummary[] {
|
||||
const domains = new Map<string, TlsSummary & { kinds: Map<TlsFailureKind, { sessions: number; hosts: string[] }> }>();
|
||||
for (const { report } of reports) {
|
||||
if (!report) continue;
|
||||
const counted = new Set<string>();
|
||||
for (const p of report.policies ?? []) {
|
||||
for (const p of jmapMapToArray<TlsPolicy>(report.policies)) {
|
||||
if (!p.policyDomain) continue;
|
||||
const name = p.policyDomain.toLowerCase();
|
||||
let d = domains.get(name);
|
||||
if (!d) {
|
||||
@@ -189,7 +197,7 @@ export function summarizeTls(reports: TlsExternalReport[]): TlsSummary[] {
|
||||
d.failed += p.totalFailedSessions ?? 0;
|
||||
const org = report.organizationName;
|
||||
if (org && !d.reporters.includes(org)) d.reporters.push(org);
|
||||
for (const f of p.failureDetails ?? []) {
|
||||
for (const f of jmapMapToArray<TlsFailure>(p.failureDetails)) {
|
||||
const kind = (f.resultType && KINDS.has(f.resultType) ? f.resultType : 'other') as TlsFailureKind;
|
||||
const k = d.kinds.get(kind) ?? { sessions: 0, hosts: [] };
|
||||
k.sessions += f.failedSessionCount ?? 0;
|
||||
|
||||
Reference in new issue
Block a user