From 71918cd6a4b7d2c8b52dac08544ef2dcdefed201 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Tue, 6 Oct 2026 09:18:41 -0700 Subject: [PATCH] Received reports: read the report lists as the server sends them The server sends every objectList as a map keyed by index, not an array, so the summary cards threw on real reports (records, DKIM and SPF results, TLS policies and failures). Read them with jmapMapToArray, skip a report without a policy domain, and show anything unexpected as an error in the card instead of breaking the page. --- .../incomingReports/DmarcSummaryCard.tsx | 13 +++++-- .../incomingReports/TlsSummaryCard.tsx | 13 +++++-- src/features/incomingReports/api.ts | 13 ++++--- .../incomingReports/summarize.test.ts | 36 +++++++++++++++++-- src/features/incomingReports/summarize.ts | 20 +++++++---- 5 files changed, 76 insertions(+), 19 deletions(-) diff --git a/src/features/incomingReports/DmarcSummaryCard.tsx b/src/features/incomingReports/DmarcSummaryCard.tsx index 413a35e..f15b606 100644 --- a/src/features/incomingReports/DmarcSummaryCard.tsx +++ b/src/features/incomingReports/DmarcSummaryCard.tsx @@ -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 (
@@ -232,8 +239,8 @@ export function DmarcSummaryCard() {

- {error ? ( -

{error}

+ {error || summaryError ? ( +

{error ?? summaryError}

) : !reports ? ( ) : summaries.length === 0 ? ( diff --git a/src/features/incomingReports/TlsSummaryCard.tsx b/src/features/incomingReports/TlsSummaryCard.tsx index fdf5346..5038928 100644 --- a/src/features/incomingReports/TlsSummaryCard.tsx +++ b/src/features/incomingReports/TlsSummaryCard.tsx @@ -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 (
@@ -83,8 +90,8 @@ export function TlsSummaryCard() {

- {error ? ( -

{error}

+ {error || summaryError ? ( +

{error ?? summaryError}

) : !reports ? ( ) : summaries.length === 0 ? ( diff --git a/src/features/incomingReports/api.ts b/src/features/incomingReports/api.ts index d3043ac..1e47f3b 100644 --- a/src/features/incomingReports/api.ts +++ b/src/features/incomingReports/api.ts @@ -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[] | Record; + 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; } export interface DmarcExternalReport { @@ -59,7 +62,7 @@ export interface TlsPolicy { policyType?: string; totalSuccessfulSessions: number; totalFailedSessions: number; - failureDetails?: TlsFailure[]; + failureDetails?: JmapList; } export interface TlsExternalReport { @@ -69,7 +72,7 @@ export interface TlsExternalReport { organizationName?: string | null; dateRangeStart: string; dateRangeEnd: string; - policies: TlsPolicy[]; + policies: JmapList; }; } diff --git a/src/features/incomingReports/summarize.test.ts b/src/features/incomingReports/summarize.test.ts index bc263db..4641aba 100644 --- a/src/features/incomingReports/summarize.test.ts +++ b/src/features/incomingReports/summarize.test.ts @@ -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 { @@ -39,6 +39,23 @@ function dmarc(records: DmarcRecord[], over: Partial = {}, 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, 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([ diff --git a/src/features/incomingReports/summarize.ts b/src/features/incomingReports/summarize.ts index 00b949f..270474c 100644 --- a/src/features/incomingReports/summarize.ts +++ b/src/features/incomingReports/summarize.ts @@ -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 { - 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(); 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(report.records)) { const count = r.count ?? 0; const ok = passes(r); const who = identify(r); @@ -173,8 +179,10 @@ const KINDS = new Set([ export function summarizeTls(reports: TlsExternalReport[]): TlsSummary[] { const domains = new Map }>(); for (const { report } of reports) { + if (!report) continue; const counted = new Set(); - for (const p of report.policies ?? []) { + for (const p of jmapMapToArray(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(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; -- 2.54.0