Received reports: who sends as your domains, and whether they pass #88

Merged
jcoffey-dev merged 1 commits from feature/incoming-reports into main 2026-10-06 16:05:49 +00:00
7 changed files with 927 additions and 0 deletions

No files matched your search

+11
View File
@@ -30,6 +30,8 @@ import { SenderChecksPage } from '@/features/senderChecks/SenderChecksPage';
import { DirectorySetupCard } from '@/features/directory/DirectorySetupCard';
import { SendingSetupCard } from '@/features/sending/SendingSetupCard';
import { CertificateSetupCard } from '@/features/certificates/CertificateSetupCard';
import { DmarcSummaryCard } from '@/features/incomingReports/DmarcSummaryCard';
import { TlsSummaryCard } from '@/features/incomingReports/TlsSummaryCard';
import type { Schema } from '@/types/schema';
function lazyFeature<M, P>(load: () => Promise<M>, select: (module: M) => ComponentType<P>) {
@@ -414,6 +416,15 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti
</div>
);
}
// inbuxa: received reports open with who sends as us and whether they pass (admin UX roadmap, item 6).
if (viewName === 'x:DmarcExternalReport' || viewName === 'x:TlsExternalReport') {
return (
<div className="space-y-4">
{viewName === 'x:DmarcExternalReport' ? <DmarcSummaryCard /> : <TlsSummaryCard />}
<DynamicList viewName={viewName} />
</div>
);
}
if (viewName === 'x:MtaRoute') {
return (
<div className="space-y-4">
@@ -0,0 +1,255 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* inbuxa: at the top of Inbound DMARC Reports (admin UX roadmap, item 6):
* who sends mail as each of our domains, whether they pass, and spoofing
* said in plain words. The list of raw reports stays underneath.
*/
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { AlertTriangle, ChevronDown, Loader2, Users } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { fetchDmarcReports, type DmarcExternalReport } from './api';
import { summarizeDmarc, type DomainSummary, type Source } from './summarize';
/** Sources shown before "Show all". */
const FIRST_SOURCES = 8;
/** Report periods are whole days in UTC; local time would show the day before. */
const day = (iso: string) => new Date(iso).toLocaleDateString(undefined, { timeZone: 'UTC' });
const num = (n: number) => n.toLocaleString();
function PolicyBadge({ policy, testing }: { policy: DomainSummary['policy']; testing: boolean }) {
const { t } = useTranslation();
const label =
policy === 'reject'
? t('inReports.policyReject', 'Policy: reject')
: policy === 'quarantine'
? t('inReports.policyQuarantine', 'Policy: quarantine')
: policy === 'none'
? t('inReports.policyNone', 'Policy: none')
: t('inReports.policyUnknown', 'Policy: not reported');
return (
<span className="rounded-full border px-2 py-0.5 text-xs text-muted-foreground">
{label}
{testing && ` · ${t('inReports.policyTesting', 'testing')}`}
</span>
);
}
function SourceRow({ source }: { source: Source }) {
const { t } = useTranslation();
const failed = source.messages - source.passed;
const { delivered, quarantined, rejected } = source.failedHandling;
return (
<tr className="border-t align-top">
<td className="py-2 pr-3">
<span className="font-medium break-all">{source.label}</span>
{source.provedBy && source.ips.length > 0 && (
<span className="block text-xs text-muted-foreground break-all">
{source.ips.slice(0, 3).join(', ')}
{source.ips.length > 3 &&
` ${t('inReports.moreIps', { count: source.ips.length - 3, defaultValue_one: '+{{count}} more', defaultValue_other: '+{{count}} more' })}`}
</span>
)}
</td>
<td className="py-2 pr-3 text-right tabular-nums">{num(source.messages)}</td>
<td className="py-2 pr-3">
{failed === 0 ? (
<span className="text-muted-foreground">
{source.provedBy === 'spf'
? t('inReports.passedSpf', 'Passed (SPF)')
: t('inReports.passedDkim', 'Passed (DKIM)')}
</span>
) : (
<span className="text-destructive">
{t('inReports.failed', 'Couldn’t prove it’s you')}
<span className="block text-xs text-muted-foreground">
{[
delivered > 0 && t('inReports.delivered', '{{count}} delivered', { count: delivered }),
quarantined > 0 && t('inReports.quarantined', '{{count}} to spam', { count: quarantined }),
rejected > 0 && t('inReports.rejected', '{{count}} rejected', { count: rejected }),
]
.filter(Boolean)
.join(', ')}
</span>
</span>
)}
</td>
<td className="py-2 text-xs text-muted-foreground">{source.reporters.join(', ')}</td>
</tr>
);
}
function DomainBlock({ summary }: { summary: DomainSummary }) {
const { t } = useTranslation();
const [open, setOpen] = useState(summary.failed > 0);
const [all, setAll] = useState(false);
const shown = all ? summary.sources : summary.sources.slice(0, FIRST_SOURCES);
const handling = summary.sources.reduce(
(acc, s) => ({
delivered: acc.delivered + s.failedHandling.delivered,
quarantined: acc.quarantined + s.failedHandling.quarantined,
rejected: acc.rejected + s.failedHandling.rejected,
}),
{ delivered: 0, quarantined: 0, rejected: 0 },
);
return (
<div className="rounded-lg border">
<button
type="button"
className="flex w-full flex-wrap items-center gap-x-3 gap-y-1 p-3 text-left"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
>
<ChevronDown className={cn('h-4 w-4 shrink-0 transition-transform', !open && '-rotate-90')} />
<span className="font-medium">{summary.domain}</span>
<span className="text-sm text-muted-foreground">
{t('inReports.passedOf', '{{passed}} of {{messages}} messages passed', {
passed: num(summary.passed),
messages: num(summary.messages),
})}
</span>
{summary.failed > 0 && (
<span className="inline-flex items-center gap-1 text-sm text-destructive">
<AlertTriangle className="h-3.5 w-3.5" />
{t('inReports.failedCount', {
count: summary.failed,
defaultValue_one: '{{count}} couldn’t prove it’s you',
defaultValue_other: '{{count}} couldn’t prove it’s you',
})}
</span>
)}
<span className="ml-auto">
<PolicyBadge policy={summary.policy} testing={summary.testing} />
</span>
</button>
{open && (
<div className="space-y-3 border-t p-3">
{summary.failed > 0 && (
<div className="rounded-md border border-destructive/30 bg-destructive/5 p-3 text-sm">
<p>
{t('inReports.spoofed', {
count: summary.failed,
domain: summary.domain,
defaultValue_one:
'{{count}} message said it was from {{domain}} but came from a server that couldn’t prove it sends for you. That is someone pretending to be you, or one of your own services that isn’t set up to sign yet.',
defaultValue_other:
'{{count}} messages said they were from {{domain}} but came from servers that couldn’t prove they send for you. That is someone pretending to be you, or one of your own services that isn’t set up to sign yet.',
})}
</p>
<p className="mt-1 text-muted-foreground">
{t(
'inReports.handled',
'Receivers delivered {{delivered}}, sent {{quarantined}} to spam and rejected {{rejected}}.',
{
delivered: num(handling.delivered),
quarantined: num(handling.quarantined),
rejected: num(handling.rejected),
},
)}
{summary.policy === 'none' &&
` ${t(
'inReports.policyNoneHint',
'Your DMARC policy is “none”, so receivers were asked to deliver them anyway. Once every real sender below passes, change the policy to quarantine or reject.',
)}`}
</p>
</div>
)}
<div className="overflow-x-auto">
<table className="w-full table-fixed text-sm">
<thead>
<tr className="text-left text-xs text-muted-foreground">
<th className="w-[38%] pb-1 pr-3 font-normal">{t('inReports.colSender', 'Sent by')}</th>
<th className="w-[12%] pb-1 pr-3 text-right font-normal">{t('inReports.colMessages', 'Messages')}</th>
<th className="w-[28%] pb-1 pr-3 font-normal">{t('inReports.colResult', 'Result')}</th>
<th className="pb-1 font-normal">{t('inReports.colReporters', 'Reported by')}</th>
</tr>
</thead>
<tbody>
{shown.map((s) => (
<SourceRow key={`${s.provedBy}:${s.label}`} source={s} />
))}
</tbody>
</table>
</div>
{summary.sources.length > FIRST_SOURCES && (
<Button type="button" variant="ghost" size="sm" onClick={() => setAll((a) => !a)}>
{all
? t('inReports.showFewer', 'Show fewer')
: t('inReports.showAll', 'Show all {{count}} senders', { count: summary.sources.length })}
</Button>
)}
<p className="text-xs text-muted-foreground">
{t('inReports.range', {
count: summary.reports,
from: day(summary.from),
until: day(summary.until),
defaultValue_one: 'From {{count}} report covering {{from}} to {{until}}.',
defaultValue_other: 'From {{count}} reports covering {{from}} to {{until}}.',
})}
</p>
</div>
)}
</div>
);
}
export function DmarcSummaryCard() {
const { t } = useTranslation();
const [reports, setReports] = useState<DmarcExternalReport[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let live = true;
fetchDmarcReports()
.then((r) => live && setReports(r))
.catch((e: unknown) => live && setError(e instanceof Error ? e.message : String(e)));
return () => {
live = false;
};
}, []);
const summaries = useMemo(() => (reports ? summarizeDmarc(reports) : []), [reports]);
return (
<section className="space-y-3 rounded-xl border p-4">
<div className="flex items-start gap-3">
<Users className="mt-0.5 h-5 w-5 shrink-0 text-primary" />
<div>
<h2 className="font-medium">{t('inReports.title', 'Who sends as your domains')}</h2>
<p className="text-sm text-muted-foreground">
{t(
'inReports.hint',
'Other mail servers report every message they got with your domain in From, and whether it proved it was really you.',
)}
</p>
</div>
</div>
{error ? (
<p className="text-sm text-destructive">{error}</p>
) : !reports ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : summaries.length === 0 ? (
<p className="text-sm text-muted-foreground">
{t(
'inReports.empty',
'No reports yet. Large providers send one a day for each domain whose DMARC record asks for them (rua=).',
)}
</p>
) : (
<div className="space-y-2">
{summaries.map((s) => (
<DomainBlock key={s.domain} summary={s} />
))}
</div>
)}
</section>
);
}
@@ -0,0 +1,137 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* inbuxa: at the top of TLS Reports (admin UX roadmap, item 6): whether other
* mail servers reached each of our domains securely, and when they couldn't,
* why, in plain words. The list of raw reports stays underneath.
*/
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { AlertTriangle, Loader2, Lock } from 'lucide-react';
import { fetchTlsReports, type TlsExternalReport } from './api';
import { summarizeTls, type TlsFailureKind } from './summarize';
const num = (n: number) => n.toLocaleString();
/** What each failure means for us, the receiving side. */
function useExplain() {
const { t } = useTranslation();
return (kind: TlsFailureKind): string => {
switch (kind) {
case 'startTlsNotSupported':
return t('inTls.startTlsNotSupported', 'Your server didn’t offer encryption (STARTTLS).');
case 'certificateHostMismatch':
return t(
'inTls.certificateHostMismatch',
'Your certificate doesn’t cover the MX host name senders connected to.',
);
case 'certificateExpired':
return t('inTls.certificateExpired', 'Your certificate had expired.');
case 'certificateNotTrusted':
return t('inTls.certificateNotTrusted', 'Senders didn’t trust who issued your certificate.');
case 'validationFailure':
return t('inTls.validationFailure', 'Senders couldn’t validate your certificate.');
case 'tlsaInvalid':
return t('inTls.tlsaInvalid', 'Your DANE (TLSA) records don’t match your certificate.');
case 'dnssecInvalid':
return t('inTls.dnssecInvalid', 'Your DNSSEC signatures didn’t validate.');
case 'daneRequired':
return t('inTls.daneRequired', 'DANE was required but senders couldn’t use it.');
case 'stsPolicyFetchError':
return t('inTls.stsPolicyFetchError', 'Senders couldn’t download your MTA-STS policy.');
case 'stsPolicyInvalid':
return t('inTls.stsPolicyInvalid', 'Your MTA-STS policy file is invalid.');
case 'stsWebpkiInvalid':
return t('inTls.stsWebpkiInvalid', 'The certificate on your MTA-STS policy site wasn’t valid.');
default:
return t('inTls.other', 'Another TLS problem the report didn’t name.');
}
};
}
export function TlsSummaryCard() {
const { t } = useTranslation();
const explain = useExplain();
const [reports, setReports] = useState<TlsExternalReport[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let live = true;
fetchTlsReports()
.then((r) => live && setReports(r))
.catch((e: unknown) => live && setError(e instanceof Error ? e.message : String(e)));
return () => {
live = false;
};
}, []);
const summaries = useMemo(() => (reports ? summarizeTls(reports) : []), [reports]);
return (
<section className="space-y-3 rounded-xl border p-4">
<div className="flex items-start gap-3">
<Lock className="mt-0.5 h-5 w-5 shrink-0 text-primary" />
<div>
<h2 className="font-medium">{t('inTls.title', 'Secure delivery to your domains')}</h2>
<p className="text-sm text-muted-foreground">
{t('inTls.hint', 'Other mail servers report whether they could reach you over an encrypted connection.')}
</p>
</div>
</div>
{error ? (
<p className="text-sm text-destructive">{error}</p>
) : !reports ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : summaries.length === 0 ? (
<p className="text-sm text-muted-foreground">
{t(
'inTls.empty',
'No reports yet. Large providers send one a day for each domain with a TLS-RPT record (_smtp._tls).',
)}
</p>
) : (
<ul className="divide-y rounded-lg border">
{summaries.map((s) => (
<li key={s.domain} className="space-y-2 p-3">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="font-medium">{s.domain}</span>
<span className="text-sm text-muted-foreground">
{t('inTls.sessions', '{{succeeded}} secure deliveries, {{failed}} failed', {
succeeded: num(s.succeeded),
failed: num(s.failed),
})}
</span>
<span className="ml-auto text-xs text-muted-foreground">{s.reporters.join(', ')}</span>
</div>
{s.failures.length > 0 && (
<ul className="space-y-1 text-sm">
{s.failures.map((f) => (
<li key={f.kind} className="flex gap-2">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-destructive" />
<span>
{explain(f.kind)}{' '}
<span className="text-muted-foreground">
{t('inTls.failedSessions', {
count: f.sessions,
defaultValue_one: '({{count}} connection',
defaultValue_other: '({{count}} connections',
})}
{f.hosts.length > 0 && `, ${f.hosts.join(', ')}`})
</span>
</span>
</li>
))}
</ul>
)}
</li>
))}
</ul>
)}
</section>
);
}
+100
View File
@@ -0,0 +1,100 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* inbuxa: the DMARC and TLS reports other mail servers send about our
* domains (admin UX roadmap, item 6). The server stores each one parsed, as
* x:DmarcExternalReport / x:TlsExternalReport; they expire on their own, so
* reading all of them stays small.
*/
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;
export type DmarcResult = 'pass' | 'fail' | 'unspecified';
export type Disposition = 'none' | 'pass' | 'quarantine' | 'reject' | 'unspecified';
export interface DmarcRecord {
sourceIp?: string | null;
count: number;
headerFrom: string;
envelopeFrom?: string;
evaluatedDisposition: Disposition;
evaluatedDkim: DmarcResult;
evaluatedSpf: DmarcResult;
dkimResults?: { domain: string; selector?: string; result: string }[];
spfResults?: { domain: string; result: string }[];
}
export interface DmarcReport {
orgName: string;
dateRangeBegin: string;
dateRangeEnd: string;
policyDomain: string;
policyDisposition?: string;
policyTestingMode?: boolean;
records: DmarcRecord[];
}
export interface DmarcExternalReport {
id: string;
receivedAt: string;
report: DmarcReport;
}
export interface TlsFailure {
resultType?: string;
failureReasonCode?: string | null;
receivingMxHostname?: string | null;
failedSessionCount: number;
}
export interface TlsPolicy {
policyDomain: string;
policyType?: string;
totalSuccessfulSessions: number;
totalFailedSessions: number;
failureDetails?: TlsFailure[];
}
export interface TlsExternalReport {
id: string;
receivedAt: string;
report: {
organizationName?: string | null;
dateRangeStart: string;
dateRangeEnd: string;
policies: TlsPolicy[];
};
}
async function readAll<T>(object: string): Promise<T[]> {
const accountId = getAccountId(object);
const responses = await jmapRequest([
[`${object}/query`, { accountId }, 'q'],
[
`${object}/get`,
{
accountId,
'#ids': { resultOf: 'q', name: `${object}/query`, path: '/ids' },
properties: ['receivedAt', 'report'],
},
'g',
],
]);
const got = responses.find(([name]) => name === `${object}/get`);
if (!got) {
const failed = responses[0]?.[1] as { description?: string; type?: string } | undefined;
throw new Error(failed?.description ?? failed?.type ?? 'Request failed');
}
const list = ((got[1] as { list?: T[] }).list ?? []) as (T & { receivedAt: string })[];
return list.sort((a, b) => b.receivedAt.localeCompare(a.receivedAt)).slice(0, REPORT_LIMIT);
}
export const fetchDmarcReports = () => readAll<DmarcExternalReport>('x:DmarcExternalReport');
export const fetchTlsReports = () => readAll<TlsExternalReport>('x:TlsExternalReport');
@@ -0,0 +1,163 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, expect, it } from 'vitest';
import type { DmarcExternalReport, DmarcRecord, DmarcReport, TlsExternalReport, TlsPolicy } from './api';
import { summarizeDmarc, summarizeTls } from './summarize';
function record(over: Partial<DmarcRecord> = {}): DmarcRecord {
return {
sourceIp: '192.0.2.10',
count: 10,
headerFrom: 'example.org',
evaluatedDisposition: 'none',
evaluatedDkim: 'pass',
evaluatedSpf: 'pass',
dkimResults: [{ domain: 'example.org', selector: 's1', result: 'pass' }],
spfResults: [{ domain: 'example.org', result: 'pass' }],
...over,
};
}
function dmarc(records: DmarcRecord[], over: Partial<DmarcReport> = {}, id = 'a'): DmarcExternalReport {
return {
id,
receivedAt: '2026-10-02T00:00:00Z',
report: {
orgName: 'google.com',
dateRangeBegin: '2026-10-01T00:00:00Z',
dateRangeEnd: '2026-10-02T00:00:00Z',
policyDomain: 'example.org',
policyDisposition: 'none',
records,
...over,
},
};
}
describe('summarizeDmarc', () => {
it('counts passing mail under the domain that proved it', () => {
const [d] = summarizeDmarc([
dmarc([record(), record({ sourceIp: '192.0.2.11', count: 5 })]),
dmarc([record({ count: 3 })], { orgName: 'Yahoo' }, 'b'),
]);
expect(d).toMatchObject({ domain: 'example.org', messages: 18, passed: 18, failed: 0, reports: 2 });
expect(d.sources).toHaveLength(1);
expect(d.sources[0]).toMatchObject({ label: 'example.org', provedBy: 'dkim', messages: 18 });
expect(d.sources[0].ips).toEqual(['192.0.2.10', '192.0.2.11']);
expect(d.sources[0].reporters).toEqual(['google.com', 'Yahoo']);
});
it('names a service by its passing SPF domain when there is no DKIM pass', () => {
const [d] = summarizeDmarc([
dmarc([
record({
evaluatedDkim: 'fail',
dkimResults: [],
spfResults: [{ domain: 'bounces.mailer.example', result: 'pass' }],
}),
]),
]);
expect(d.sources[0]).toMatchObject({ label: 'bounces.mailer.example', provedBy: 'spf', passed: 10 });
});
it('lists failing senders by IP, first, with what receivers did', () => {
const fail: Partial<DmarcRecord> = { evaluatedDkim: 'fail', evaluatedSpf: 'fail', dkimResults: [], spfResults: [] };
const [d] = summarizeDmarc([
dmarc([
record({ count: 100 }),
record({ ...fail, sourceIp: '203.0.113.5', count: 4, evaluatedDisposition: 'reject' }),
record({ ...fail, sourceIp: '203.0.113.5', count: 2, evaluatedDisposition: 'none' }),
record({ ...fail, sourceIp: '203.0.113.9', count: 1, evaluatedDisposition: 'quarantine' }),
]),
]);
expect(d).toMatchObject({ messages: 107, passed: 100, failed: 7 });
expect(d.sources.map((s) => s.label)).toEqual(['203.0.113.5', '203.0.113.9', 'example.org']);
expect(d.sources[0].failedHandling).toEqual({ delivered: 2, quarantined: 0, rejected: 4 });
expect(d.sources[0].provedBy).toBeNull();
});
it('takes the policy from the newest report', () => {
const [d] = summarizeDmarc([
dmarc([record()], { policyDisposition: 'reject', dateRangeEnd: '2026-10-05T00:00:00Z' }, 'new'),
dmarc([record()], { policyDisposition: 'none', dateRangeEnd: '2026-09-30T00:00:00Z' }, 'old'),
]);
expect(d.policy).toBe('reject');
expect(d.until).toBe('2026-10-05T00:00:00Z');
});
it('puts the domain with failures first', () => {
const out = summarizeDmarc([
dmarc([record({ count: 1000 })], { policyDomain: 'busy.example' }, '1'),
dmarc(
[record({ evaluatedDkim: 'fail', evaluatedSpf: 'fail', count: 1 })],
{ policyDomain: 'Quiet.example' },
'2',
),
]);
expect(out.map((d) => d.domain)).toEqual(['quiet.example', 'busy.example']);
});
});
function tls(policies: TlsPolicy[], org = 'google.com'): TlsExternalReport {
return {
id: org,
receivedAt: '2026-10-02T00:00:00Z',
report: {
organizationName: org,
dateRangeStart: '2026-10-01T00:00:00Z',
dateRangeEnd: '2026-10-02T00:00:00Z',
policies,
},
};
}
describe('summarizeTls', () => {
it('adds up sessions per domain and groups failures by kind', () => {
const [d] = summarizeTls([
tls([
{
policyDomain: 'example.org',
totalSuccessfulSessions: 50,
totalFailedSessions: 3,
failureDetails: [
{ resultType: 'certificateExpired', failedSessionCount: 2, receivingMxHostname: 'mx.example.org.' },
{ resultType: 'somethingNew', failedSessionCount: 1 },
],
},
]),
tls(
[
{
policyDomain: 'example.org',
totalSuccessfulSessions: 10,
totalFailedSessions: 1,
failureDetails: [
{ resultType: 'certificateExpired', failedSessionCount: 1, receivingMxHostname: 'mx.example.org' },
],
},
],
'Microsoft',
),
]);
expect(d).toMatchObject({ domain: 'example.org', succeeded: 60, failed: 4, reports: 2 });
expect(d.failures).toEqual([
{ kind: 'certificateExpired', sessions: 3, hosts: ['mx.example.org'] },
{ kind: 'other', sessions: 1, hosts: [] },
]);
expect(d.reporters).toEqual(['google.com', 'Microsoft']);
});
it('puts domains with failures first', () => {
const out = summarizeTls([
tls([
{ policyDomain: 'a.example', totalSuccessfulSessions: 9, totalFailedSessions: 0 },
{ policyDomain: 'b.example', totalSuccessfulSessions: 1, totalFailedSessions: 1 },
]),
]);
expect(out.map((d) => d.domain)).toEqual(['b.example', 'a.example']);
});
});
+208
View File
@@ -0,0 +1,208 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* inbuxa: turns received DMARC and TLS reports into "who sends as your
* domain, and do they pass" (admin UX roadmap, item 6). Pure, so it can be
* tested without a server.
*/
import type { Disposition, DmarcExternalReport, DmarcRecord, TlsExternalReport } from './api';
/** One server (or service) that sent mail with one of our domains in From. */
export interface Source {
/** What the sender proved it was: the domain whose DKIM signature or SPF passed, else its IP address. */
label: string;
/** How it proved it, when it did. */
provedBy: 'dkim' | 'spf' | null;
ips: string[];
messages: number;
/** Messages that passed DMARC (an aligned DKIM or SPF pass). */
passed: number;
/** What receivers did with the messages that failed. */
failedHandling: Record<'delivered' | 'quarantined' | 'rejected', number>;
reporters: string[];
}
export interface DomainSummary {
domain: string;
messages: number;
passed: number;
failed: number;
/** The policy the newest report saw published. */
policy: 'none' | 'quarantine' | 'reject' | 'unknown';
testing: boolean;
/** Failing sources first, then the busiest. */
sources: Source[];
from: string;
until: string;
reports: number;
}
const passes = (r: DmarcRecord) => r.evaluatedDkim === 'pass' || r.evaluatedSpf === 'pass';
function handling(d: Disposition): keyof Source['failedHandling'] {
if (d === 'reject') return 'rejected';
if (d === 'quarantine') return 'quarantined';
return 'delivered';
}
/** 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);
if (dkim) return { label: dkim.domain.toLowerCase(), provedBy: 'dkim' };
const spf = r.spfResults?.find((s) => s.result === 'pass' && s.domain);
if (spf) return { label: spf.domain.toLowerCase(), provedBy: 'spf' };
return { label: r.sourceIp ?? '?', provedBy: null };
}
export function summarizeDmarc(reports: DmarcExternalReport[]): DomainSummary[] {
const domains = new Map<string, DomainSummary & { sourceMap: Map<string, Source> }>();
/** Per domain, the end of the newest report, whose policy wins. */
const newest = new Map<string, string>();
for (const { report } of reports) {
const name = report.policyDomain.toLowerCase();
let d = domains.get(name);
if (!d) {
d = {
domain: name,
messages: 0,
passed: 0,
failed: 0,
policy: 'unknown',
testing: false,
sources: [],
from: report.dateRangeBegin,
until: report.dateRangeEnd,
reports: 0,
sourceMap: new Map(),
};
domains.set(name, d);
}
d.reports += 1;
if (report.dateRangeBegin < d.from) d.from = report.dateRangeBegin;
if (report.dateRangeEnd > d.until) d.until = report.dateRangeEnd;
if (report.dateRangeEnd >= (newest.get(name) ?? '')) {
newest.set(name, report.dateRangeEnd);
const p = report.policyDisposition;
d.policy = p === 'none' || p === 'quarantine' || p === 'reject' ? p : 'unknown';
d.testing = report.policyTestingMode === true;
}
for (const r of report.records ?? []) {
const count = r.count ?? 0;
const ok = passes(r);
const who = identify(r);
// A failing sender proved nothing, so each IP is its own source.
const key = ok ? `${who.provedBy}:${who.label}` : `ip:${r.sourceIp ?? '?'}`;
let s = d.sourceMap.get(key);
if (!s) {
s = {
...(ok ? who : { label: r.sourceIp ?? '?', provedBy: null }),
ips: [],
messages: 0,
passed: 0,
failedHandling: { delivered: 0, quarantined: 0, rejected: 0 },
reporters: [],
};
d.sourceMap.set(key, s);
}
s.messages += count;
d.messages += count;
if (ok) {
s.passed += count;
d.passed += count;
} else {
s.failedHandling[handling(r.evaluatedDisposition)] += count;
d.failed += count;
}
if (r.sourceIp && !s.ips.includes(r.sourceIp)) s.ips.push(r.sourceIp);
if (report.orgName && !s.reporters.includes(report.orgName)) s.reporters.push(report.orgName);
}
}
return [...domains.values()]
.map(({ sourceMap, ...d }) => ({
...d,
sources: [...sourceMap.values()].sort(
(a, b) => b.messages - b.passed - (a.messages - a.passed) || b.messages - a.messages,
),
}))
.sort((a, b) => b.failed - a.failed || b.messages - a.messages || a.domain.localeCompare(b.domain));
}
export type TlsFailureKind =
| 'startTlsNotSupported'
| 'certificateHostMismatch'
| 'certificateExpired'
| 'certificateNotTrusted'
| 'validationFailure'
| 'tlsaInvalid'
| 'dnssecInvalid'
| 'daneRequired'
| 'stsPolicyFetchError'
| 'stsPolicyInvalid'
| 'stsWebpkiInvalid'
| 'other';
export interface TlsSummary {
domain: string;
succeeded: number;
failed: number;
/** Failure kinds, most sessions first. */
failures: { kind: TlsFailureKind; sessions: number; hosts: string[] }[];
reporters: string[];
reports: number;
}
const KINDS = new Set<string>([
'startTlsNotSupported',
'certificateHostMismatch',
'certificateExpired',
'certificateNotTrusted',
'validationFailure',
'tlsaInvalid',
'dnssecInvalid',
'daneRequired',
'stsPolicyFetchError',
'stsPolicyInvalid',
'stsWebpkiInvalid',
]);
export function summarizeTls(reports: TlsExternalReport[]): TlsSummary[] {
const domains = new Map<string, TlsSummary & { kinds: Map<TlsFailureKind, { sessions: number; hosts: string[] }> }>();
for (const { report } of reports) {
const counted = new Set<string>();
for (const p of report.policies ?? []) {
const name = p.policyDomain.toLowerCase();
let d = domains.get(name);
if (!d) {
d = { domain: name, succeeded: 0, failed: 0, failures: [], reporters: [], reports: 0, kinds: new Map() };
domains.set(name, d);
}
if (!counted.has(name)) {
d.reports += 1;
counted.add(name);
}
d.succeeded += p.totalSuccessfulSessions ?? 0;
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 ?? []) {
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;
const host = f.receivingMxHostname?.replace(/\.$/, '');
if (host && !k.hosts.includes(host)) k.hosts.push(host);
d.kinds.set(kind, k);
}
}
}
return [...domains.values()]
.map(({ kinds, ...d }) => ({
...d,
failures: [...kinds.entries()].map(([kind, k]) => ({ kind, ...k })).sort((a, b) => b.sessions - a.sessions),
}))
.sort((a, b) => b.failed - a.failed || a.domain.localeCompare(b.domain));
}
+53
View File
@@ -2088,5 +2088,58 @@
"noCert": "No certificate for {{name}}",
"noCertWhy": "Senders connecting to {{name}} get the wrong certificate, and those that check it won’t deliver.",
"listSite": "Open the list’s site"
},
"inReports": {
"policyReject": "Policy: reject",
"policyQuarantine": "Policy: quarantine",
"policyNone": "Policy: none",
"policyUnknown": "Policy: not reported",
"policyTesting": "testing",
"moreIps_one": "+{{count}} more",
"moreIps_other": "+{{count}} more",
"passedSpf": "Passed (SPF)",
"passedDkim": "Passed (DKIM)",
"failed": "Couldn’t prove it’s you",
"delivered": "{{count}} delivered",
"quarantined": "{{count}} to spam",
"rejected": "{{count}} rejected",
"passedOf": "{{passed}} of {{messages}} messages passed",
"failedCount_one": "{{count}} couldn’t prove it’s you",
"failedCount_other": "{{count}} couldn’t prove it’s you",
"spoofed_one": "{{count}} message said it was from {{domain}} but came from a server that couldn’t prove it sends for you. That is someone pretending to be you, or one of your own services that isn’t set up to sign yet.",
"spoofed_other": "{{count}} messages said they were from {{domain}} but came from servers that couldn’t prove they send for you. That is someone pretending to be you, or one of your own services that isn’t set up to sign yet.",
"handled": "Receivers delivered {{delivered}}, sent {{quarantined}} to spam and rejected {{rejected}}.",
"policyNoneHint": "Your DMARC policy is “none”, so receivers were asked to deliver them anyway. Once every real sender below passes, change the policy to quarantine or reject.",
"colSender": "Sent by",
"colMessages": "Messages",
"colResult": "Result",
"colReporters": "Reported by",
"showFewer": "Show fewer",
"showAll": "Show all {{count}} senders",
"range_one": "From {{count}} report covering {{from}} to {{until}}.",
"range_other": "From {{count}} reports covering {{from}} to {{until}}.",
"title": "Who sends as your domains",
"hint": "Other mail servers report every message they got with your domain in From, and whether it proved it was really you.",
"empty": "No reports yet. Large providers send one a day for each domain whose DMARC record asks for them (rua=)."
},
"inTls": {
"startTlsNotSupported": "Your server didn’t offer encryption (STARTTLS).",
"certificateHostMismatch": "Your certificate doesn’t cover the MX host name senders connected to.",
"certificateExpired": "Your certificate had expired.",
"certificateNotTrusted": "Senders didn’t trust who issued your certificate.",
"validationFailure": "Senders couldn’t validate your certificate.",
"tlsaInvalid": "Your DANE (TLSA) records don’t match your certificate.",
"dnssecInvalid": "Your DNSSEC signatures didn’t validate.",
"daneRequired": "DANE was required but senders couldn’t use it.",
"stsPolicyFetchError": "Senders couldn’t download your MTA-STS policy.",
"stsPolicyInvalid": "Your MTA-STS policy file is invalid.",
"stsWebpkiInvalid": "The certificate on your MTA-STS policy site wasn’t valid.",
"other": "Another TLS problem the report didn’t name.",
"title": "Secure delivery to your domains",
"hint": "Other mail servers report whether they could reach you over an encrypted connection.",
"empty": "No reports yet. Large providers send one a day for each domain with a TLS-RPT record (_smtp._tls).",
"sessions": "{{succeeded}} secure deliveries, {{failed}} failed",
"failedSessions_one": "({{count}} connection",
"failedSessions_other": "({{count}} connections"
}
}