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