Domains › Deliverability: what other mail servers see of this one #83
No files matched your search
@@ -135,6 +135,11 @@ const SharedMailboxesPage = lazyFeature(
|
||||
() => import('@/features/lock/SharedMailboxesPage'),
|
||||
(m) => m.SharedMailboxesPage,
|
||||
);
|
||||
// inbuxa: Domains › Deliverability (deliverability spec, DL-17).
|
||||
const DeliverabilityPage = lazyFeature(
|
||||
() => import('@/features/deliverability/DeliverabilityPage'),
|
||||
(m) => m.DeliverabilityPage,
|
||||
);
|
||||
const LegalHoldsPage = lazyFeature(
|
||||
() => import('@/features/hold/LegalHoldsPage'),
|
||||
(m) => m.LegalHoldsPage,
|
||||
@@ -246,6 +251,9 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti
|
||||
if (componentName === 'SharedMailboxes') {
|
||||
return <SharedMailboxesPage />;
|
||||
}
|
||||
if (componentName === 'Deliverability') {
|
||||
return <DeliverabilityPage />;
|
||||
}
|
||||
if (componentName === 'LegalHolds') {
|
||||
return <LegalHoldsPage />;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
FileWarning,
|
||||
HardDrive,
|
||||
ListX,
|
||||
MailWarning,
|
||||
RadioTower,
|
||||
RotateCw,
|
||||
ServerCrash,
|
||||
@@ -38,6 +39,9 @@ export interface AttentionInput {
|
||||
facts: ServerFacts | null;
|
||||
health: ClusterHealth | null;
|
||||
critical: number | null;
|
||||
/** inbuxa: blocklist and reverse-DNS failures on Domains › Deliverability (DL-18). */
|
||||
deliverability?: number | null;
|
||||
deliverabilityHref?: string | null;
|
||||
samples: Metric[];
|
||||
liveStatus: string;
|
||||
nodesHref: string | null;
|
||||
@@ -52,6 +56,8 @@ export function buildAttention({
|
||||
facts,
|
||||
health,
|
||||
critical,
|
||||
deliverability,
|
||||
deliverabilityHref,
|
||||
samples,
|
||||
liveStatus,
|
||||
nodesHref,
|
||||
@@ -75,6 +81,20 @@ export function buildAttention({
|
||||
detail: t('cc.a.securityDetail', 'On the Security to-do list and not yet accepted.'),
|
||||
href: `/Settings/${LEGACY_PROTOCOLS_VIEW}`,
|
||||
});
|
||||
if (deliverability)
|
||||
attention.push({
|
||||
id: 'deliverability',
|
||||
severity: 'crit',
|
||||
icon: MailWarning,
|
||||
count: deliverability,
|
||||
title: t('cc.a.deliverability', {
|
||||
count: deliverability,
|
||||
defaultValue_one: 'blocklist or reverse-DNS problem',
|
||||
defaultValue_other: 'blocklist or reverse-DNS problems',
|
||||
}),
|
||||
detail: t('cc.a.deliverabilityDetail', 'Other mail servers may refuse mail from this one.'),
|
||||
href: deliverabilityHref ?? null,
|
||||
});
|
||||
if (health && health.unhealthy)
|
||||
attention.push({
|
||||
id: 'nodes',
|
||||
|
||||
@@ -12,6 +12,8 @@ import { usePermissions } from '@/hooks/usePermissions';
|
||||
import { LEGACY_PROTOCOLS_VIEW } from '@/features/hardening/LegacyProtocolsBanner';
|
||||
import { useLegacyProtocolsOff } from '@/features/hardening/useLegacyProtocolsOff';
|
||||
import { useCriticalCount } from '@/features/security/useCriticalCount';
|
||||
import { useDeliverabilityAlarms } from '@/features/deliverability/useDeliverabilityAlarms';
|
||||
import { useSchemaStore } from '@/stores/schemaStore';
|
||||
import type { Dashboard } from '../types/schema';
|
||||
import type { Metric } from '../types/metrics';
|
||||
import { useDashboardStore } from '../stores/dashboardStore';
|
||||
@@ -60,6 +62,9 @@ export function DashboardShell({
|
||||
const facts = useServerFacts(tick).facts;
|
||||
const cluster = useClusterState(tick);
|
||||
const critical = useCriticalCount();
|
||||
// inbuxa: DL-18
|
||||
const deliverability = useDeliverabilityAlarms(tick);
|
||||
const deliverabilitySection = useSchemaStore((s) => s.viewToSection['CustomComponent/Deliverability']);
|
||||
const legacyOff = useLegacyProtocolsOff();
|
||||
|
||||
const cacheKey = `${COMMAND_CACHE}|${periodKey(period)}`;
|
||||
@@ -99,6 +104,8 @@ export function DashboardShell({
|
||||
facts,
|
||||
health,
|
||||
critical,
|
||||
deliverability,
|
||||
deliverabilityHref: deliverabilitySection ? `/${deliverabilitySection}/CustomComponent/Deliverability` : null,
|
||||
samples,
|
||||
liveStatus,
|
||||
nodesHref: health ? `/${section}/Dashboard/cluster` : null,
|
||||
|
||||
@@ -0,0 +1,417 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* inbuxa: Domains › Deliverability (deliverability spec, DL-17): what other
|
||||
* mail servers see when this one sends. The sending nodes first, then the
|
||||
* domains, worst finding first, each with one fix. Like the Security page:
|
||||
* no score, and no "all good" banner; what passed is folded away.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ArrowUpRight, ChevronDown, Loader2, RefreshCw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { LoadingFallback } from '@/components/common/LoadingFallback';
|
||||
import { getAccountId, jmapRequest } from '@/services/jmap/client';
|
||||
import { useSchemaStore } from '@/stores/schemaStore';
|
||||
import { useAccountStore } from '@/stores/accountStore';
|
||||
import { resolveObject } from '@/lib/schemaResolver';
|
||||
import { toast } from '@/hooks/use-toast';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
checkNow,
|
||||
DeliverabilityUnavailable,
|
||||
fetchReports,
|
||||
fetchSettings,
|
||||
setDisabledLists,
|
||||
type Report,
|
||||
type Settings,
|
||||
} from './api';
|
||||
import { grade, type Finding, type Fix, type Grade } from './grade';
|
||||
|
||||
/** DL-15: a node asked again within this long keeps its report. */
|
||||
const MIN_INTERVAL_MS = 10 * 60_000;
|
||||
const POLL_MS = 5_000;
|
||||
const POLL_FOR_MS = 3 * 60_000;
|
||||
|
||||
const GRADE_STYLE: Record<Grade, string> = {
|
||||
fail: 'bg-destructive/10 text-destructive border-destructive/30',
|
||||
warn: 'bg-amber-500/10 text-amber-700 dark:text-amber-400 border-amber-500/30',
|
||||
unknown: 'bg-muted text-muted-foreground border-border',
|
||||
};
|
||||
|
||||
interface Loaded {
|
||||
reports: Report[];
|
||||
settings: Settings;
|
||||
/** Domain name → id, for the fix links. */
|
||||
domainIds: Map<string, string>;
|
||||
}
|
||||
|
||||
async function domainIds(): Promise<Map<string, string>> {
|
||||
const responses = await jmapRequest([
|
||||
['x:Domain/get', { accountId: getAccountId('x:Domain'), ids: null, properties: ['name'] }, '0'],
|
||||
]);
|
||||
const [, body] = responses[0] ?? [];
|
||||
const list = (body as { list?: { id: string; name: string }[] } | undefined)?.list ?? [];
|
||||
return new Map(list.map((d) => [d.name.toLowerCase(), d.id]));
|
||||
}
|
||||
|
||||
export function DeliverabilityPage() {
|
||||
const { t } = useTranslation();
|
||||
const schema = useSchemaStore((s) => s.schema);
|
||||
const viewToSection = useSchemaStore((s) => s.viewToSection);
|
||||
const hasPermission = useAccountStore((s) => s.hasPermission);
|
||||
const canCheck = hasPermission('sysDeliverabilityCheck');
|
||||
const canChangeLists = hasPermission('sysDeliverabilityUpdate');
|
||||
const [loaded, setLoaded] = useState<Loaded | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [unavailable, setUnavailable] = useState(false);
|
||||
const [waiting, setWaiting] = useState<{ id: string; since: string | null; until: number } | null>(null);
|
||||
const [showPassed, setShowPassed] = useState(false);
|
||||
const [savingList, setSavingList] = useState<string | null>(null);
|
||||
const timer = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
const load = useCallback(async (): Promise<Loaded> => {
|
||||
const [reports, settings, ids] = await Promise.all([
|
||||
fetchReports(),
|
||||
fetchSettings(),
|
||||
domainIds().catch(() => new Map<string, string>()),
|
||||
]);
|
||||
return { reports, settings, domainIds: ids };
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const next = await load();
|
||||
setLoaded(next);
|
||||
setError(null);
|
||||
return next;
|
||||
} catch (e) {
|
||||
if (e instanceof DeliverabilityUnavailable) setUnavailable(true);
|
||||
else setError(e instanceof Error ? e.message : String(e));
|
||||
return null;
|
||||
}
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
// DL-15: after Check now, look again until the node's report is newer
|
||||
useEffect(() => {
|
||||
if (!waiting) return;
|
||||
timer.current = setInterval(() => {
|
||||
void refresh().then((next) => {
|
||||
const mine = next?.reports.find((r) => r.id === waiting.id);
|
||||
if (mine && mine.checkedAt !== waiting.since) {
|
||||
setWaiting(null);
|
||||
} else if (Date.now() > waiting.until) {
|
||||
setWaiting(null);
|
||||
toast({
|
||||
title: t('deliv.stillChecking', 'Still checking'),
|
||||
description: t('deliv.stillCheckingWhy', 'The new results will be here when you come back to this page.'),
|
||||
});
|
||||
}
|
||||
});
|
||||
}, POLL_MS);
|
||||
return () => {
|
||||
if (timer.current) clearInterval(timer.current);
|
||||
};
|
||||
}, [waiting, refresh, t]);
|
||||
|
||||
const graded = useMemo(
|
||||
() => (loaded ? grade(loaded.reports, loaded.settings.lists, (k, d, o) => t(k, d, o)) : null),
|
||||
[loaded, t],
|
||||
);
|
||||
|
||||
const newest = useMemo(() => {
|
||||
const times = loaded?.reports.map((r) => Date.parse(r.checkedAt)).filter((n) => !Number.isNaN(n)) ?? [];
|
||||
return times.length ? Math.max(...times) : null;
|
||||
}, [loaded]);
|
||||
const tooSoon = newest !== null && Date.now() - newest < MIN_INTERVAL_MS;
|
||||
|
||||
const startCheck = async () => {
|
||||
try {
|
||||
const { id, checkedAt } = await checkNow();
|
||||
setWaiting({ id, since: checkedAt, until: Date.now() + POLL_FOR_MS });
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: t('deliv.checkFailed', 'Not started'),
|
||||
description: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const toggleList = async (name: string, on: boolean) => {
|
||||
if (!loaded) return;
|
||||
const off = new Set(loaded.settings.disabledLists);
|
||||
if (on) off.delete(name);
|
||||
else off.add(name);
|
||||
setSavingList(name);
|
||||
try {
|
||||
await setDisabledLists([...off]);
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
toast({
|
||||
variant: 'destructive',
|
||||
title: t('deliv.listSaveFailed', 'Not saved'),
|
||||
description: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
} finally {
|
||||
setSavingList(null);
|
||||
}
|
||||
};
|
||||
|
||||
const viewHref = (viewName: string) => {
|
||||
const resolved = schema ? resolveObject(schema, viewName) : null;
|
||||
const base = `/${viewToSection[viewName] ?? 'Settings'}/${viewName}`;
|
||||
return resolved?.objectType.type === 'singleton' ? `${base}/singleton` : base;
|
||||
};
|
||||
|
||||
const fixLink = (fix: Fix) => {
|
||||
switch (fix.kind) {
|
||||
case 'list':
|
||||
return (
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<a href={fix.url} target="_blank" rel="noopener noreferrer">
|
||||
{fix.removal
|
||||
? t('deliv.lookUp', 'Look up and ask for removal')
|
||||
: t('deliv.listSite', 'Open the list’s site')}
|
||||
<ArrowUpRight className="ml-1 h-3.5 w-3.5" />
|
||||
</a>
|
||||
</Button>
|
||||
);
|
||||
case 'domain': {
|
||||
const id = loaded?.domainIds.get(fix.domain);
|
||||
const section = viewToSection['x:Domain'] ?? 'Management';
|
||||
return (
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<Link to={id ? `/${section}/x:Domain/${encodeURIComponent(id)}` : `/${section}/x:Domain`}>
|
||||
{t('deliv.openDomain', 'Open the domain')}
|
||||
</Link>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
case 'view':
|
||||
return (
|
||||
<Button asChild size="sm" variant="outline">
|
||||
<Link to={viewHref(fix.viewName)}>{t('deliv.review', 'Review')}</Link>
|
||||
</Button>
|
||||
);
|
||||
case 'elsewhere':
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('deliv.elsewhere', 'Set with your hosting provider or DNS host, not here.')}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const gradeLabel: Record<Grade, string> = {
|
||||
fail: t('deliv.fail', 'Fail'),
|
||||
warn: t('deliv.warn', 'Warn'),
|
||||
unknown: t('deliv.unknown', "Couldn't check"),
|
||||
};
|
||||
|
||||
if (unavailable) {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl rounded-lg border border-dashed p-12 text-center text-muted-foreground">
|
||||
{t('deliv.unavailable', 'This server doesn’t have the deliverability check yet.')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error && !loaded) {
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl rounded-lg border border-dashed p-12 text-center text-muted-foreground">
|
||||
{error}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!loaded || !graded) return <LoadingFallback />;
|
||||
|
||||
const { reports, settings } = loaded;
|
||||
const fails = graded.findings.filter((f) => f.grade === 'fail').length;
|
||||
const groups = (scope: 'node' | 'domain') => {
|
||||
const map = new Map<string, Finding[]>();
|
||||
for (const f of graded.findings.filter((x) => x.scope === scope)) {
|
||||
const list = map.get(f.owner);
|
||||
if (list) list.push(f);
|
||||
else map.set(f.owner, [f]);
|
||||
}
|
||||
return map;
|
||||
};
|
||||
const nodeGroups = groups('node');
|
||||
const domainGroups = groups('domain');
|
||||
const notChecked = graded.passed.filter((p) => p.notChecked);
|
||||
|
||||
const card = (f: Finding) => (
|
||||
<li key={f.key} className="space-y-3 rounded-xl border bg-card p-4">
|
||||
<div className="flex flex-wrap items-start gap-3">
|
||||
<span className={cn('shrink-0 rounded-full border px-2 py-0.5 text-xs font-medium', GRADE_STYLE[f.grade])}>
|
||||
{gradeLabel[f.grade]}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<p className="font-medium">{f.title}</p>
|
||||
<p className="text-sm text-muted-foreground">{f.why}</p>
|
||||
</div>
|
||||
</div>
|
||||
{fixLink(f.fix)}
|
||||
</li>
|
||||
);
|
||||
|
||||
const section = (title: string, map: Map<string, Finding[]>) =>
|
||||
map.size > 0 && (
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">{title}</h2>
|
||||
{[...map.entries()].map(([owner, findings]) => (
|
||||
<div key={owner} className="space-y-2">
|
||||
<h3 className="font-mono text-sm">{owner}</h3>
|
||||
<ul className="space-y-3">{findings.map(card)}</ul>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-3xl space-y-8">
|
||||
<header className="space-y-1">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h1 className="text-2xl font-semibold">{t('deliv.title', 'Deliverability')}</h1>
|
||||
{canCheck && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={() => void startCheck()}
|
||||
disabled={waiting !== null || tooSoon}
|
||||
title={
|
||||
tooSoon && newest
|
||||
? t('deliv.tooSoon', 'Checked at {{time}}; again from {{next}}', {
|
||||
time: new Date(newest).toLocaleTimeString(),
|
||||
next: new Date(newest + MIN_INTERVAL_MS).toLocaleTimeString(),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{waiting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <RefreshCw className="mr-2 h-4 w-4" />}
|
||||
{waiting ? t('deliv.checking', 'Checking…') : t('deliv.checkNow', 'Check now')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
{t(
|
||||
'deliv.subtitle',
|
||||
'What other mail servers see when this one sends: blocklists, reverse DNS, SPF, DKIM, DMARC, MTA-STS and certificates. Every sending node checks itself once a day.',
|
||||
)}
|
||||
</p>
|
||||
{reports.length > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{reports
|
||||
.map((r) =>
|
||||
t('deliv.checkedOn', '{{node}} checked {{when}}', {
|
||||
node: r.hostname,
|
||||
when: new Date(r.checkedAt).toLocaleString(),
|
||||
}),
|
||||
)
|
||||
.join(' · ')}
|
||||
</p>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{reports.length === 0 && (
|
||||
<p className="rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground">
|
||||
{canCheck
|
||||
? t('deliv.neverChecked', 'No node has checked yet. Check now, or wait for the daily check.')
|
||||
: t('deliv.neverCheckedTenant', 'Not checked yet. The server checks once a day.')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{graded.findings.length > 0 && (
|
||||
<p className="text-sm font-medium">
|
||||
{fails > 0
|
||||
? t('deliv.countsFail', '{{all}} to look at, {{fails}} failing', {
|
||||
all: graded.findings.length,
|
||||
fails,
|
||||
})
|
||||
: t('deliv.counts', '{{all}} to look at', { all: graded.findings.length })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{section(t('deliv.nodes', 'Sending addresses'), nodeGroups)}
|
||||
{section(t('deliv.domains', 'Domains'), domainGroups)}
|
||||
|
||||
{graded.passed.length > 0 && (
|
||||
<section>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassed((v) => !v)}
|
||||
className="flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground"
|
||||
aria-expanded={showPassed}
|
||||
>
|
||||
<ChevronDown className={cn('h-4 w-4 transition-transform', !showPassed && '-rotate-90')} />
|
||||
{t('deliv.passed', 'Passed checks ({{count}})', { count: graded.passed.length - notChecked.length })}
|
||||
{notChecked.length > 0 &&
|
||||
t('deliv.notCheckedCount', ', {{count}} not checked', { count: notChecked.length })}
|
||||
</button>
|
||||
{showPassed && (
|
||||
<ul className="mt-2 space-y-1 text-sm">
|
||||
{graded.passed.map((p) => (
|
||||
<li key={p.key} className="flex flex-wrap gap-x-2">
|
||||
<span className="font-mono text-xs text-muted-foreground">{p.owner}</span>
|
||||
<span className={p.notChecked ? 'text-muted-foreground' : ''}>{p.title}</span>
|
||||
{p.notChecked && <span className="text-muted-foreground">— {p.notChecked}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{canChangeLists && settings.lists.length > 0 && (
|
||||
<section className="space-y-3">
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
{t('deliv.lists', 'Blocklists asked')}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
'deliv.listsWhy',
|
||||
'Each list sees the addresses and domains it’s asked about, as it would whenever anyone checks mail from this server.',
|
||||
)}
|
||||
</p>
|
||||
<ul className="divide-y rounded-xl border bg-card">
|
||||
{settings.lists.map((list) => {
|
||||
const on = !settings.disabledLists.includes(list.name);
|
||||
return (
|
||||
<li key={list.name} className="flex items-start gap-3 px-4 py-3">
|
||||
<Switch
|
||||
id={`list-${list.name}`}
|
||||
checked={on}
|
||||
disabled={savingList !== null}
|
||||
onCheckedChange={(v) => void toggleList(list.name, v)}
|
||||
aria-label={list.name}
|
||||
/>
|
||||
<div className="min-w-0 flex-1 space-y-0.5">
|
||||
<label htmlFor={`list-${list.name}`} className="text-sm font-medium">
|
||||
{list.name}{' '}
|
||||
<span className="font-normal text-muted-foreground">
|
||||
· {list.scope === 'ip' ? t('deliv.scopeIp', 'addresses') : t('deliv.scopeDomain', 'domains')}
|
||||
</span>
|
||||
</label>
|
||||
{list.note && <p className="text-xs text-muted-foreground">{list.note}</p>}
|
||||
</div>
|
||||
{savingList === list.name && <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* inbuxa: the deliverability check's server objects (deliverability spec):
|
||||
* one report per sending node, facts only, and which blocklists are left out.
|
||||
*/
|
||||
|
||||
import { getAccountId, jmapRequest } from '@/services/jmap/client';
|
||||
import { INBUXA_CAPABILITY } from '@/features/hardening/protocolPolicy';
|
||||
|
||||
export type ListingState = 'clean' | 'listed' | 'refused' | 'error' | 'off';
|
||||
|
||||
export interface Listing {
|
||||
list: string;
|
||||
state: ListingState;
|
||||
code?: string | null;
|
||||
meaning?: string | null;
|
||||
}
|
||||
|
||||
export interface Address {
|
||||
ip: string;
|
||||
source: 'configured' | 'ehlo';
|
||||
strategy: string;
|
||||
ehlo: string;
|
||||
ptr: string[];
|
||||
forwardConfirmed: boolean;
|
||||
ehloMatches: boolean;
|
||||
ptrError?: string | null;
|
||||
listings: Listing[];
|
||||
}
|
||||
|
||||
export interface DomainReport {
|
||||
domain: string;
|
||||
tenantId?: number | null;
|
||||
spf: { ip: string; result: string }[];
|
||||
dkim: { selector: string; state: 'matches' | 'missing' | 'different' | 'error' }[];
|
||||
dmarc?: { policy: string; adkim: string; aspf: string } | null;
|
||||
mtaSts: {
|
||||
recordId?: string | null;
|
||||
fetched: boolean;
|
||||
error?: string | null;
|
||||
mode?: string | null;
|
||||
maxAge?: number | null;
|
||||
mxNotCovered: string[];
|
||||
};
|
||||
tlsRpt: boolean;
|
||||
listings: Listing[];
|
||||
}
|
||||
|
||||
export interface Report {
|
||||
id: string;
|
||||
nodeId: number;
|
||||
hostname: string;
|
||||
checkedAt: string;
|
||||
addresses: Address[];
|
||||
domains: DomainReport[];
|
||||
certificates: { name: string; covered: boolean }[];
|
||||
}
|
||||
|
||||
export interface ListInfo {
|
||||
name: string;
|
||||
zone: string;
|
||||
scope: 'ip' | 'domain';
|
||||
lookup: string;
|
||||
note?: string | null;
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
disabledLists: string[];
|
||||
lists: ListInfo[];
|
||||
}
|
||||
|
||||
/** The server doesn't have the check (an older version). */
|
||||
export class DeliverabilityUnavailable extends Error {}
|
||||
|
||||
async function call(method: string, args: Record<string, unknown>): Promise<Record<string, unknown>> {
|
||||
const responses = await jmapRequest([[method, { accountId: getAccountId('x:Domain'), ...args }, '0']], undefined, [
|
||||
INBUXA_CAPABILITY,
|
||||
]);
|
||||
const [name, result] = responses[0] ?? [];
|
||||
if (name !== method) {
|
||||
const r = result as { type?: string; description?: string } | undefined;
|
||||
if (r?.type === 'unknownMethod' || r?.type === 'unknownCapability') throw new DeliverabilityUnavailable(r.type);
|
||||
throw new Error(r?.description ?? r?.type ?? 'Request failed');
|
||||
}
|
||||
return result as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function fetchReports(): Promise<Report[]> {
|
||||
const result = await call('inbuxa:DeliverabilityReport/get', { ids: null });
|
||||
return (result.list as Report[]) ?? [];
|
||||
}
|
||||
|
||||
export async function fetchSettings(): Promise<Settings> {
|
||||
const result = await call('inbuxa:DeliverabilitySettings/get', { ids: null });
|
||||
const settings = (result.list as Settings[] | undefined)?.[0];
|
||||
return settings ?? { disabledLists: [], lists: [] };
|
||||
}
|
||||
|
||||
export async function setDisabledLists(names: string[]): Promise<void> {
|
||||
const result = await call('inbuxa:DeliverabilitySettings/set', {
|
||||
update: { singleton: { disabledLists: names } },
|
||||
});
|
||||
const failed = (result.notUpdated as Record<string, { description?: string; type?: string }> | undefined)?.singleton;
|
||||
if (failed) throw new Error(failed.description ?? failed.type);
|
||||
}
|
||||
|
||||
/**
|
||||
* DL-15: asks every node to check itself now. Returns at once with the
|
||||
* receiving node's id and when it last checked; the new report replaces
|
||||
* that one when the check is done.
|
||||
*/
|
||||
export async function checkNow(): Promise<{ id: string; checkedAt: string | null }> {
|
||||
const result = await call('inbuxa:DeliverabilityReport/set', { create: { now: {} } });
|
||||
const created = (result.created as Record<string, { id: string; checkedAt: string | null }> | undefined)?.now;
|
||||
if (!created) {
|
||||
const failed = (result.notCreated as Record<string, { description?: string }> | undefined)?.now;
|
||||
throw new Error(failed?.description ?? 'Not started');
|
||||
}
|
||||
return created;
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { Address, DomainReport, ListInfo, Report } from './api';
|
||||
import { alarms, grade, worstFor } from './grade';
|
||||
|
||||
const t = (_key: string, fallback: string, options?: Record<string, unknown>) =>
|
||||
fallback.replace(/\{\{(\w+)\}\}/g, (_m, k: string) => String(options?.[k] ?? ''));
|
||||
|
||||
const LISTS: ListInfo[] = [
|
||||
{ name: 'Spamhaus ZEN', zone: 'zen.spamhaus.org', scope: 'ip', lookup: 'https://check.spamhaus.org/' },
|
||||
{ name: 'Spamhaus DBL', zone: 'dbl.spamhaus.org', scope: 'domain', lookup: 'https://check.spamhaus.org/' },
|
||||
];
|
||||
|
||||
function address(over: Partial<Address> = {}): Address {
|
||||
return {
|
||||
ip: '192.0.2.10',
|
||||
source: 'ehlo',
|
||||
strategy: 'default',
|
||||
ehlo: 'mx.example.org',
|
||||
ptr: ['mx.example.org'],
|
||||
forwardConfirmed: true,
|
||||
ehloMatches: true,
|
||||
listings: [{ list: 'Spamhaus ZEN', state: 'clean' }],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function domain(over: Partial<DomainReport> = {}): DomainReport {
|
||||
return {
|
||||
domain: 'example.org',
|
||||
spf: [{ ip: '192.0.2.10', result: 'pass' }],
|
||||
dkim: [{ selector: 'rsa', state: 'matches' }],
|
||||
dmarc: { policy: 'reject', adkim: 'relaxed', aspf: 'relaxed' },
|
||||
mtaSts: { recordId: null, fetched: false, mxNotCovered: [] },
|
||||
tlsRpt: false,
|
||||
listings: [{ list: 'Spamhaus DBL', state: 'clean' }],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
function report(addresses: Address[], domains: DomainReport[], over: Partial<Report> = {}): Report {
|
||||
return {
|
||||
id: 'a',
|
||||
nodeId: 1,
|
||||
hostname: 'mx.example.org',
|
||||
checkedAt: '2026-10-05T00:10:00Z',
|
||||
addresses,
|
||||
domains,
|
||||
certificates: [{ name: 'mx.example.org', covered: true }],
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
const rules = (r: Report[]) => grade(r, LISTS, t).findings.map((f) => `${f.rule}:${f.grade}`);
|
||||
|
||||
describe('grade', () => {
|
||||
it('finds nothing wrong with a server in order', () => {
|
||||
const graded = grade([report([address()], [domain()])], LISTS, t);
|
||||
expect(graded.findings).toEqual([]);
|
||||
expect(graded.passed.length).toBeGreaterThan(4);
|
||||
});
|
||||
|
||||
it('fails a listing, and never reads a refusal as one', () => {
|
||||
const graded = grade(
|
||||
[
|
||||
report(
|
||||
[
|
||||
address({
|
||||
listings: [
|
||||
{ list: 'Spamhaus ZEN', state: 'listed', code: '127.0.0.2', meaning: 'SBL: a known spam source' },
|
||||
{ list: 'SpamCop', state: 'refused', meaning: 'Too many queries' },
|
||||
{ list: 'Barracuda', state: 'off' },
|
||||
],
|
||||
}),
|
||||
],
|
||||
[],
|
||||
),
|
||||
],
|
||||
LISTS,
|
||||
t,
|
||||
);
|
||||
expect(graded.findings.map((f) => [f.grade, f.title])).toEqual([
|
||||
['fail', '192.0.2.10 is on Spamhaus ZEN'],
|
||||
['unknown', "Couldn't check 192.0.2.10 on SpamCop"],
|
||||
]);
|
||||
expect(graded.findings[0].fix).toEqual({ kind: 'list', url: 'https://check.spamhaus.org/', removal: true });
|
||||
expect(graded.passed.find((p) => p.title.includes('Barracuda'))?.notChecked).toBe('switched off');
|
||||
});
|
||||
|
||||
it('grades reverse DNS: missing, not back, another name', () => {
|
||||
expect(rules([report([address({ ptr: [] })], [])])).toEqual(['DL-5:fail']);
|
||||
expect(rules([report([address({ forwardConfirmed: false, ehloMatches: false })], [])])).toEqual(['DL-5:fail']);
|
||||
expect(rules([report([address({ ehloMatches: false })], [])])).toEqual(['DL-5:warn']);
|
||||
});
|
||||
|
||||
it('fails SPF for the node it doesn’t cover, naming it', () => {
|
||||
const graded = grade(
|
||||
[
|
||||
report([address()], [domain()]),
|
||||
report(
|
||||
[address({ ip: '198.51.100.7', ehlo: 'mx2.example.org', ptr: ['mx2.example.org'] })],
|
||||
[domain({ spf: [{ ip: '198.51.100.7', result: 'fail' }] })],
|
||||
{ id: 'b', nodeId: 2, hostname: 'mx2.example.org' },
|
||||
),
|
||||
],
|
||||
LISTS,
|
||||
t,
|
||||
);
|
||||
const spf = graded.findings.filter((f) => f.rule === 'DL-7');
|
||||
expect(spf).toHaveLength(1);
|
||||
expect(spf[0].title).toContain('198.51.100.7 (mx2.example.org)');
|
||||
});
|
||||
|
||||
it('grades DKIM keys missing or different', () => {
|
||||
expect(rules([report([], [domain({ dkim: [{ selector: 'rsa', state: 'different' }] })])])).toContain('DL-8:fail');
|
||||
expect(rules([report([], [domain({ dkim: [{ selector: 'rsa', state: 'missing' }] })])])).toContain('DL-8:fail');
|
||||
});
|
||||
|
||||
it('fails DMARC only when neither DKIM nor SPF vouches', () => {
|
||||
const neither = domain({
|
||||
dkim: [{ selector: 'rsa', state: 'missing' }],
|
||||
spf: [{ ip: '192.0.2.10', result: 'fail' }],
|
||||
});
|
||||
expect(rules([report([], [neither])])).toContain('DL-9:fail');
|
||||
const dkimOnly = domain({ spf: [{ ip: '192.0.2.10', result: 'fail' }] });
|
||||
expect(rules([report([], [dkimOnly])])).not.toContain('DL-9:fail');
|
||||
// No DMARC record: the Security page's business, not this one's
|
||||
expect(rules([report([], [domain({ dmarc: null, dkim: [], spf: [] })])])).toEqual([]);
|
||||
});
|
||||
|
||||
it('grades MTA-STS and wants TLS reporting with it', () => {
|
||||
const uncovered = domain({
|
||||
mtaSts: { recordId: 'x', fetched: true, mode: 'enforce', mxNotCovered: ['mx2.example.org'] },
|
||||
tlsRpt: true,
|
||||
});
|
||||
expect(rules([report([], [uncovered])])).toEqual(['DL-10:fail']);
|
||||
const testing = domain({ mtaSts: { recordId: 'x', fetched: true, mode: 'testing', mxNotCovered: [] } });
|
||||
expect(rules([report([], [testing])])).toEqual(['DL-10:warn', 'DL-11:warn']);
|
||||
const unfetched = domain({ mtaSts: { recordId: 'x', fetched: false, error: 'timed out', mxNotCovered: [] } });
|
||||
expect(rules([report([], [unfetched])])).toContain('DL-10:fail');
|
||||
});
|
||||
|
||||
it('fails a name with no certificate', () => {
|
||||
expect(rules([report([], [], { certificates: [{ name: 'mx.example.org', covered: false }] })])).toEqual([
|
||||
'DL-13:fail',
|
||||
]);
|
||||
});
|
||||
|
||||
it('puts failures first, then warnings, then what couldn’t be checked', () => {
|
||||
const graded = grade(
|
||||
[
|
||||
report(
|
||||
[address({ ehloMatches: false, listings: [{ list: 'SpamCop', state: 'error' }] })],
|
||||
[domain({ dkim: [{ selector: 'rsa', state: 'missing' }] })],
|
||||
),
|
||||
],
|
||||
LISTS,
|
||||
t,
|
||||
);
|
||||
expect(graded.findings.map((f) => f.grade)).toEqual(['fail', 'warn', 'unknown']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('alarms and worstFor', () => {
|
||||
const graded = grade(
|
||||
[
|
||||
report(
|
||||
[address({ ptr: [], listings: [{ list: 'Spamhaus ZEN', state: 'listed' }] })],
|
||||
[domain({ domain: 'bad.example.org', dkim: [{ selector: 'rsa', state: 'missing' }] })],
|
||||
),
|
||||
],
|
||||
LISTS,
|
||||
t,
|
||||
);
|
||||
|
||||
it('sends only blocklist and reverse-DNS failures to Needs attention', () => {
|
||||
expect(alarms(graded).map((f) => f.rule)).toEqual(['DL-4', 'DL-5']);
|
||||
});
|
||||
|
||||
it('gives a domain’s worst failure for its hover card', () => {
|
||||
expect(worstFor(graded, 'bad.example.org')?.rule).toBe('DL-8');
|
||||
expect(worstFor(graded, 'example.org')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,413 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* inbuxa: grading the deliverability reports (deliverability spec, DL-4 to
|
||||
* DL-13). The server keeps facts; this turns them into findings, each Fail,
|
||||
* Warn or Couldn't check, with one sentence on what's wrong, one on why it
|
||||
* matters, and one fix. What passed is kept too, for the "Passed checks"
|
||||
* fold. Pure, so the wording can change without a server release.
|
||||
*/
|
||||
|
||||
import type { Address, DomainReport, Listing, ListInfo, Report } from './api';
|
||||
|
||||
export type Grade = 'fail' | 'warn' | 'unknown';
|
||||
|
||||
export type Fix =
|
||||
/** The list's own site: to ask for removal, or, when it wouldn't answer, to see why. */
|
||||
| { kind: 'list'; url: string; removal: boolean }
|
||||
| { kind: 'domain'; domain: string }
|
||||
| { kind: 'view'; viewName: string }
|
||||
/** Something only the hosting provider or the DNS host can change. */
|
||||
| { kind: 'elsewhere' };
|
||||
|
||||
export interface Finding {
|
||||
/** Stable across runs: rule, node and subject. */
|
||||
key: string;
|
||||
rule: string;
|
||||
grade: Grade;
|
||||
/** 'node' findings belong to a node's addresses; 'domain' ones to a domain. */
|
||||
scope: 'node' | 'domain';
|
||||
/** The node's hostname, or the domain. */
|
||||
owner: string;
|
||||
title: string;
|
||||
why: string;
|
||||
fix: Fix;
|
||||
}
|
||||
|
||||
export interface Passed {
|
||||
key: string;
|
||||
scope: 'node' | 'domain';
|
||||
owner: string;
|
||||
title: string;
|
||||
/** Set when the check wasn't asked, with why. */
|
||||
notChecked?: string;
|
||||
}
|
||||
|
||||
export interface Graded {
|
||||
findings: Finding[];
|
||||
passed: Passed[];
|
||||
}
|
||||
|
||||
type T = (key: string, fallback: string, options?: Record<string, unknown>) => string;
|
||||
|
||||
const ORDER: Record<Grade, number> = { fail: 0, warn: 1, unknown: 2 };
|
||||
|
||||
function listFix(lists: ListInfo[], name: string, removal: boolean): Fix {
|
||||
const info = lists.find((l) => l.name === name);
|
||||
return info ? { kind: 'list', url: info.lookup, removal } : { kind: 'elsewhere' };
|
||||
}
|
||||
|
||||
function gradeListings(
|
||||
t: T,
|
||||
lists: ListInfo[],
|
||||
scope: 'node' | 'domain',
|
||||
owner: string,
|
||||
subject: string,
|
||||
rule: string,
|
||||
listings: Listing[],
|
||||
out: Graded,
|
||||
) {
|
||||
for (const l of listings) {
|
||||
const key = `${rule}|${owner}|${subject}|${l.list}`;
|
||||
if (l.state === 'listed') {
|
||||
out.findings.push({
|
||||
key,
|
||||
rule,
|
||||
grade: 'fail',
|
||||
scope,
|
||||
owner,
|
||||
title: t('deliv.listed', '{{subject}} is on {{list}}', { subject, list: l.list }),
|
||||
why: l.meaning
|
||||
? t('deliv.listedWhy', '{{meaning}}. Servers that use this list refuse or file away its mail.', {
|
||||
meaning: l.meaning,
|
||||
})
|
||||
: t('deliv.listedWhyPlain', 'Servers that use this list refuse or file away its mail.'),
|
||||
fix: listFix(lists, l.list, true),
|
||||
});
|
||||
} else if (l.state === 'refused' || l.state === 'error') {
|
||||
// A list that only answers registered resolvers says so (Barracuda)
|
||||
const note = lists.find((x) => x.name === l.list)?.note;
|
||||
out.findings.push({
|
||||
key,
|
||||
rule,
|
||||
grade: 'unknown',
|
||||
scope,
|
||||
owner,
|
||||
title: t('deliv.unchecked', "Couldn't check {{subject}} on {{list}}", { subject, list: l.list }),
|
||||
why: note ?? l.meaning ?? t('deliv.uncheckedWhy', 'The list gave no usable answer.'),
|
||||
fix: listFix(lists, l.list, false),
|
||||
});
|
||||
} else if (l.state === 'off') {
|
||||
out.passed.push({
|
||||
key,
|
||||
scope,
|
||||
owner,
|
||||
title: t('deliv.onList', '{{subject}} on {{list}}', { subject, list: l.list }),
|
||||
notChecked: t('deliv.listOff', 'switched off'),
|
||||
});
|
||||
} else {
|
||||
out.passed.push({
|
||||
key,
|
||||
scope,
|
||||
owner,
|
||||
title: t('deliv.notListed', '{{subject}} isn’t on {{list}}', { subject, list: l.list }),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function gradeAddress(t: T, lists: ListInfo[], owner: string, a: Address, out: Graded) {
|
||||
gradeListings(t, lists, 'node', owner, a.ip, 'DL-4', a.listings, out);
|
||||
const key = `DL-5|${owner}|${a.ip}`;
|
||||
const base = { key, rule: 'DL-5', scope: 'node' as const, owner, fix: { kind: 'elsewhere' as const } };
|
||||
if (a.ptrError) {
|
||||
out.findings.push({
|
||||
...base,
|
||||
grade: 'unknown',
|
||||
title: t('deliv.ptrUnknown', "Couldn't look up {{ip}}'s reverse DNS", { ip: a.ip }),
|
||||
why: a.ptrError,
|
||||
});
|
||||
} else if (a.ptr.length === 0) {
|
||||
out.findings.push({
|
||||
...base,
|
||||
grade: 'fail',
|
||||
title: t('deliv.noPtr', '{{ip}} has no reverse DNS', { ip: a.ip }),
|
||||
why: t(
|
||||
'deliv.noPtrWhy',
|
||||
'Most large mail services refuse mail from an address with no name. Your hosting provider sets it; ask for {{ehlo}}.',
|
||||
{ ehlo: a.ehlo },
|
||||
),
|
||||
});
|
||||
} else if (!a.forwardConfirmed) {
|
||||
out.findings.push({
|
||||
...base,
|
||||
grade: 'fail',
|
||||
title: t('deliv.ptrNotBack', '{{ip}}’s reverse DNS doesn’t lead back to it', { ip: a.ip }),
|
||||
why: t(
|
||||
'deliv.ptrNotBackWhy',
|
||||
'{{ptr}} doesn’t resolve to {{ip}}, so receivers treat the name as made up. Point {{ptr}} at {{ip}}, or change the reverse DNS.',
|
||||
{ ptr: a.ptr[0], ip: a.ip },
|
||||
),
|
||||
});
|
||||
} else if (!a.ehloMatches) {
|
||||
out.findings.push({
|
||||
...base,
|
||||
grade: 'warn',
|
||||
title: t('deliv.ptrNotEhlo', '{{ip}}’s reverse DNS isn’t the name it greets with', { ip: a.ip }),
|
||||
why: t(
|
||||
'deliv.ptrNotEhloWhy',
|
||||
'It says {{ptr}}; the server greets as {{ehlo}}. Some receivers score that as suspicious.',
|
||||
{ ptr: a.ptr[0], ehlo: a.ehlo },
|
||||
),
|
||||
});
|
||||
} else {
|
||||
out.passed.push({
|
||||
key,
|
||||
scope: 'node',
|
||||
owner,
|
||||
title: t('deliv.ptrOk', '{{ip}} resolves to {{ptr}} and back', { ip: a.ip, ptr: a.ptr[0] }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function gradeDomain(t: T, lists: ListInfo[], node: string, d: DomainReport, out: Graded) {
|
||||
const owner = d.domain;
|
||||
const toDomain: Fix = { kind: 'domain', domain: d.domain };
|
||||
|
||||
// DL-7
|
||||
for (const s of d.spf) {
|
||||
const key = `DL-7|${owner}|${s.ip}`;
|
||||
if (s.result === 'pass') {
|
||||
out.passed.push({
|
||||
key,
|
||||
scope: 'domain',
|
||||
owner,
|
||||
title: t('deliv.spfOk', 'SPF lets {{ip}} ({{node}}) send', { ip: s.ip, node }),
|
||||
});
|
||||
} else if (s.result === 'tempError') {
|
||||
out.findings.push({
|
||||
key,
|
||||
rule: 'DL-7',
|
||||
grade: 'unknown',
|
||||
scope: 'domain',
|
||||
owner,
|
||||
title: t('deliv.spfUnknown', "Couldn't check SPF for {{ip}}", { ip: s.ip }),
|
||||
why: t('deliv.spfUnknownWhy', 'The SPF lookup failed for now; it’s tried again at the next check.'),
|
||||
fix: toDomain,
|
||||
});
|
||||
} else {
|
||||
out.findings.push({
|
||||
key,
|
||||
rule: 'DL-7',
|
||||
grade: 'fail',
|
||||
scope: 'domain',
|
||||
owner,
|
||||
title:
|
||||
s.result === 'none'
|
||||
? t('deliv.spfNone', '{{domain}} has no SPF record', { domain: owner })
|
||||
: t('deliv.spfFail', 'SPF doesn’t let {{ip}} ({{node}}) send for {{domain}}', {
|
||||
ip: s.ip,
|
||||
node,
|
||||
domain: owner,
|
||||
}),
|
||||
why: t(
|
||||
'deliv.spfWhy',
|
||||
'Receivers check SPF on every message; mail from an address it doesn’t name is often refused. Add the address, or publish the record the domain page suggests.',
|
||||
),
|
||||
fix: toDomain,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// DL-8
|
||||
for (const k of d.dkim) {
|
||||
const key = `DL-8|${owner}|${k.selector}`;
|
||||
if (k.state === 'matches') {
|
||||
out.passed.push({
|
||||
key,
|
||||
scope: 'domain',
|
||||
owner,
|
||||
title: t('deliv.dkimOk', 'DKIM key {{selector}} is published as signed', { selector: k.selector }),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
out.findings.push({
|
||||
key,
|
||||
rule: 'DL-8',
|
||||
grade: k.state === 'error' ? 'unknown' : 'fail',
|
||||
scope: 'domain',
|
||||
owner,
|
||||
title:
|
||||
k.state === 'missing'
|
||||
? t('deliv.dkimMissing', 'DKIM key {{selector}} isn’t in DNS', { selector: k.selector })
|
||||
: k.state === 'different'
|
||||
? t('deliv.dkimDifferent', 'DNS has a different key for DKIM selector {{selector}}', {
|
||||
selector: k.selector,
|
||||
})
|
||||
: t('deliv.dkimUnknown', "Couldn't check DKIM key {{selector}}", { selector: k.selector }),
|
||||
why: t(
|
||||
'deliv.dkimWhy',
|
||||
'Every message is signed with this key; while DNS doesn’t have it, the signature fails at the receiver. Publish the record from the domain page.',
|
||||
),
|
||||
fix: toDomain,
|
||||
});
|
||||
}
|
||||
|
||||
// DL-9: aligned through DKIM (the domain signs as itself) or SPF
|
||||
if (d.dmarc) {
|
||||
const key = `DL-9|${owner}`;
|
||||
const dkimAligned = d.dkim.some((k) => k.state === 'matches');
|
||||
const spfAligned = d.spf.length > 0 && d.spf.every((s) => s.result === 'pass');
|
||||
if (dkimAligned || spfAligned) {
|
||||
out.passed.push({ key, scope: 'domain', owner, title: t('deliv.dmarcOk', 'DMARC aligns') });
|
||||
} else {
|
||||
out.findings.push({
|
||||
key,
|
||||
rule: 'DL-9',
|
||||
grade: 'fail',
|
||||
scope: 'domain',
|
||||
owner,
|
||||
title: t('deliv.dmarcFail', 'Mail from {{domain}} doesn’t pass DMARC', { domain: owner }),
|
||||
why: t(
|
||||
'deliv.dmarcWhy',
|
||||
'Neither a DKIM key in DNS nor SPF vouches for every address, so receivers apply the domain’s DMARC policy ({{policy}}).',
|
||||
{ policy: d.dmarc.policy },
|
||||
),
|
||||
fix: toDomain,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// DL-10, DL-11
|
||||
const sts = d.mtaSts;
|
||||
if (sts.recordId) {
|
||||
const key = `DL-10|${owner}`;
|
||||
if (!sts.fetched) {
|
||||
out.findings.push({
|
||||
key,
|
||||
rule: 'DL-10',
|
||||
grade: 'fail',
|
||||
scope: 'domain',
|
||||
owner,
|
||||
title: t('deliv.stsFetch', '{{domain}}’s MTA-STS policy can’t be fetched', { domain: owner }),
|
||||
why: t(
|
||||
'deliv.stsFetchWhy',
|
||||
'The DNS record says there is one, so senders that enforce MTA-STS may hold mail for {{domain}}. {{error}}',
|
||||
{ domain: owner, error: sts.error ?? '' },
|
||||
),
|
||||
fix: toDomain,
|
||||
});
|
||||
} else if (sts.mxNotCovered.length > 0) {
|
||||
out.findings.push({
|
||||
key,
|
||||
rule: 'DL-10',
|
||||
grade: 'fail',
|
||||
scope: 'domain',
|
||||
owner,
|
||||
title: t('deliv.stsMx', '{{domain}}’s MTA-STS policy leaves out {{mx}}', {
|
||||
domain: owner,
|
||||
mx: sts.mxNotCovered.join(', '),
|
||||
}),
|
||||
why: t(
|
||||
'deliv.stsMxWhy',
|
||||
'Senders that enforce the policy won’t deliver to an MX it doesn’t name. Add it to the policy’s MX hosts.',
|
||||
),
|
||||
fix: { kind: 'view', viewName: 'x:MtaSts' },
|
||||
});
|
||||
} else if (sts.mode === 'testing') {
|
||||
out.findings.push({
|
||||
key,
|
||||
rule: 'DL-10',
|
||||
grade: 'warn',
|
||||
scope: 'domain',
|
||||
owner,
|
||||
title: t('deliv.stsTesting', '{{domain}}’s MTA-STS policy is in testing mode', { domain: owner }),
|
||||
why: t(
|
||||
'deliv.stsTestingWhy',
|
||||
'Senders report problems but still deliver without TLS if it fails. Switch to enforce once the reports are clean.',
|
||||
),
|
||||
fix: { kind: 'view', viewName: 'x:MtaSts' },
|
||||
});
|
||||
} else {
|
||||
out.passed.push({ key, scope: 'domain', owner, title: t('deliv.stsOk', 'MTA-STS policy covers every MX') });
|
||||
}
|
||||
const rptKey = `DL-11|${owner}`;
|
||||
if (!d.tlsRpt) {
|
||||
out.findings.push({
|
||||
key: rptKey,
|
||||
rule: 'DL-11',
|
||||
grade: 'warn',
|
||||
scope: 'domain',
|
||||
owner,
|
||||
title: t('deliv.noTlsRpt', '{{domain}} has no TLS reporting record', { domain: owner }),
|
||||
why: t(
|
||||
'deliv.noTlsRptWhy',
|
||||
'With MTA-STS in place, failed TLS deliveries go unreported. Publish the TLS-RPT record from the domain page.',
|
||||
),
|
||||
fix: toDomain,
|
||||
});
|
||||
} else {
|
||||
out.passed.push({ key: rptKey, scope: 'domain', owner, title: t('deliv.tlsRptOk', 'TLS reporting is on') });
|
||||
}
|
||||
}
|
||||
|
||||
// DL-12
|
||||
gradeListings(t, lists, 'domain', owner, owner, 'DL-12', d.listings, out);
|
||||
}
|
||||
|
||||
/** Every finding and pass across the nodes' reports, worst first. */
|
||||
export function grade(reports: Report[], lists: ListInfo[], t: T): Graded {
|
||||
const out: Graded = { findings: [], passed: [] };
|
||||
for (const r of reports) {
|
||||
for (const a of r.addresses) gradeAddress(t, lists, r.hostname, a, out);
|
||||
// DL-13
|
||||
for (const c of r.certificates) {
|
||||
const key = `DL-13|${r.hostname}|${c.name}`;
|
||||
if (c.covered) {
|
||||
out.passed.push({
|
||||
key,
|
||||
scope: 'node',
|
||||
owner: r.hostname,
|
||||
title: t('deliv.certOk', 'Certificate for {{name}}', { name: c.name }),
|
||||
});
|
||||
} else {
|
||||
out.findings.push({
|
||||
key,
|
||||
rule: 'DL-13',
|
||||
grade: 'fail',
|
||||
scope: 'node',
|
||||
owner: r.hostname,
|
||||
title: t('deliv.noCert', 'No certificate for {{name}}', { name: c.name }),
|
||||
why: t(
|
||||
'deliv.noCertWhy',
|
||||
'Senders connecting to {{name}} get the wrong certificate, and those that check it won’t deliver.',
|
||||
{ name: c.name },
|
||||
),
|
||||
fix: { kind: 'view', viewName: 'x:Certificate' },
|
||||
});
|
||||
}
|
||||
}
|
||||
for (const d of r.domains) gradeDomain(t, lists, r.hostname, d, out);
|
||||
}
|
||||
// A domain finding that every node shares (no address in it) once
|
||||
const seen = new Set<string>();
|
||||
out.findings = out.findings.filter((f) => (seen.has(f.key) ? false : (seen.add(f.key), true)));
|
||||
const passedSeen = new Set<string>();
|
||||
out.passed = out.passed.filter((p) => (passedSeen.has(p.key) ? false : (passedSeen.add(p.key), true)));
|
||||
out.findings.sort((a, b) => ORDER[a.grade] - ORDER[b.grade]);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** DL-18: what goes to the dashboard's Needs attention: blocklist and reverse-DNS failures. */
|
||||
export function alarms(graded: Graded): Finding[] {
|
||||
return graded.findings.filter((f) => f.grade === 'fail' && (f.rule === 'DL-4' || f.rule === 'DL-5'));
|
||||
}
|
||||
|
||||
/** DL-19: the worst finding for a domain, for its hover card. */
|
||||
export function worstFor(graded: Graded, domain: string): Finding | null {
|
||||
return graded.findings.find((f) => f.scope === 'domain' && f.owner === domain && f.grade === 'fail') ?? null;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* inbuxa: how many blocklist and reverse-DNS failures the deliverability
|
||||
* reports hold, for the dashboard's Needs attention (deliverability spec,
|
||||
* DL-18). Only for whoever may read the reports; null until known, or when
|
||||
* it can't be (an older server).
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAccountStore } from '@/stores/accountStore';
|
||||
import { fetchReports, fetchSettings } from './api';
|
||||
import { alarms, grade } from './grade';
|
||||
|
||||
export function useDeliverabilityAlarms(tick: number): number | null {
|
||||
const { t } = useTranslation();
|
||||
const allowed = useAccountStore((s) => s.hasObjectPermission('sysDeliverability', 'Get'));
|
||||
const [count, setCount] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!allowed) return;
|
||||
let live = true;
|
||||
Promise.all([fetchReports(), fetchSettings()])
|
||||
.then(([reports, settings]) => {
|
||||
if (live) setCount(alarms(grade(reports, settings.lists, (k, d, o) => t(k, d, o))).length);
|
||||
})
|
||||
.catch(() => live && setCount(null));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [allowed, tick, t]);
|
||||
|
||||
return allowed ? count : null;
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CheckCircle2, CircleDashed, Globe, Loader2, UserRound } from 'lucide-react';
|
||||
import { CheckCircle2, CircleDashed, Globe, Loader2, MailWarning, UserRound } from 'lucide-react';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { formatSize } from '@/lib/durationFormat';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -67,6 +67,12 @@ function DomainCard({ f }: { f: DomainFacts }) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{f.deliverability && (
|
||||
<p className="flex items-start gap-1.5 text-destructive">
|
||||
<MailWarning className="mt-0.5 h-3.5 w-3.5 shrink-0" />
|
||||
{f.deliverability}
|
||||
</p>
|
||||
)}
|
||||
<div className="space-y-1 border-t pt-2">
|
||||
<Row label={t('hover.dns', 'DNS records')}>{auto(f.dns)}</Row>
|
||||
<Row label={t('hover.dkim', 'Signing keys')}>{auto(f.dkim)}</Row>
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
import { getAccountId, jmapRequest } from '@/services/jmap/client';
|
||||
import { parseZone, type RecordKind, type ZoneRecord } from '@/features/dns/zone';
|
||||
import { checkRecords } from '@/features/dns/liveCheck';
|
||||
import i18n from '@/i18n';
|
||||
import { useAccountStore } from '@/stores/accountStore';
|
||||
import { fetchReports, fetchSettings } from '@/features/deliverability/api';
|
||||
import { grade, worstFor } from '@/features/deliverability/grade';
|
||||
|
||||
export interface DomainFacts {
|
||||
kind: 'domain';
|
||||
@@ -22,6 +26,8 @@ export interface DomainFacts {
|
||||
certs: string;
|
||||
/** The core records, and which are live in public DNS. */
|
||||
checks: { kind: RecordKind; live: boolean }[];
|
||||
/** inbuxa: DL-19, the domain's worst deliverability failure, if any. */
|
||||
deliverability?: string;
|
||||
}
|
||||
|
||||
export interface PersonFacts {
|
||||
@@ -58,6 +64,14 @@ export async function coreChecks(zone: ZoneRecord[], domain: string): Promise<{
|
||||
}));
|
||||
}
|
||||
|
||||
/** DL-19: the worst failing deliverability finding for a domain, for whoever may read the reports. */
|
||||
async function deliverabilityFor(domain: string): Promise<string | undefined> {
|
||||
if (!useAccountStore.getState().hasObjectPermission('sysDeliverability', 'Get')) return undefined;
|
||||
const [reports, settings] = await Promise.all([fetchReports(), fetchSettings()]);
|
||||
const t = i18n.t.bind(i18n) as (key: string, fallback: string, options?: Record<string, unknown>) => string;
|
||||
return worstFor(grade(reports, settings.lists, t), domain.toLowerCase())?.title;
|
||||
}
|
||||
|
||||
async function domainFacts(id: string): Promise<DomainFacts | null> {
|
||||
const accountId = getAccountId('x:Domain');
|
||||
const responses = await jmapRequest([
|
||||
@@ -80,9 +94,10 @@ async function domainFacts(id: string): Promise<DomainFacts | null> {
|
||||
if (!d) return null;
|
||||
const count = responses.find((r) => r[2] === 'n' && r[0] !== 'error')?.[1] as { total?: number } | undefined;
|
||||
const name = String(d.name);
|
||||
const checks = await coreChecks(parseZone(d.dnsZoneFile as string | undefined), name).catch(
|
||||
(): DomainFacts['checks'] => [],
|
||||
);
|
||||
const [checks, deliverability] = await Promise.all([
|
||||
coreChecks(parseZone(d.dnsZoneFile as string | undefined), name).catch((): DomainFacts['checks'] => []),
|
||||
deliverabilityFor(name).catch(() => undefined),
|
||||
]);
|
||||
return {
|
||||
kind: 'domain',
|
||||
name,
|
||||
@@ -92,6 +107,7 @@ async function domainFacts(id: string): Promise<DomainFacts | null> {
|
||||
dkim: mode(d.dkimManagement),
|
||||
certs: mode(d.certificateManagement),
|
||||
checks,
|
||||
deliverability,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+80
-1
@@ -1791,7 +1791,10 @@
|
||||
"reports_other": "reports with warnings",
|
||||
"reportsDetail": "Other providers flagged how your mail arrived ({{dmarc}} DMARC, {{tls}} TLS).",
|
||||
"feed": "Live feed unavailable",
|
||||
"feedDetail": "Counts come from the server’s records; connection meters are dark."
|
||||
"feedDetail": "Counts come from the server’s records; connection meters are dark.",
|
||||
"deliverability_one": "blocklist or reverse-DNS problem",
|
||||
"deliverability_other": "blocklist or reverse-DNS problems",
|
||||
"deliverabilityDetail": "Other mail servers may refuse mail from this one."
|
||||
},
|
||||
"attention_one": "Needs attention · {{count}}",
|
||||
"attention_other": "Needs attention · {{count}}",
|
||||
@@ -2009,5 +2012,81 @@
|
||||
"tenantScope": "For everyone in this organization. It can be stricter than the server, never looser.",
|
||||
"serverScope": "For everyone on this server. Each organization can be stricter on its own page.",
|
||||
"serverOff": "Off for the whole server."
|
||||
},
|
||||
"deliv": {
|
||||
"stillChecking": "Still checking",
|
||||
"stillCheckingWhy": "The new results will be here when you come back to this page.",
|
||||
"checkFailed": "Not started",
|
||||
"listSaveFailed": "Not saved",
|
||||
"lookUp": "Look up and ask for removal",
|
||||
"openDomain": "Open the domain",
|
||||
"review": "Review",
|
||||
"elsewhere": "Set with your hosting provider or DNS host, not here.",
|
||||
"fail": "Fail",
|
||||
"warn": "Warn",
|
||||
"unknown": "Couldn't check",
|
||||
"unavailable": "This server doesn’t have the deliverability check yet.",
|
||||
"title": "Deliverability",
|
||||
"tooSoon": "Checked at {{time}}; again from {{next}}",
|
||||
"checking": "Checking…",
|
||||
"checkNow": "Check now",
|
||||
"subtitle": "What other mail servers see when this one sends: blocklists, reverse DNS, SPF, DKIM, DMARC, MTA-STS and certificates. Every sending node checks itself once a day.",
|
||||
"checkedOn": "{{node}} checked {{when}}",
|
||||
"neverChecked": "No node has checked yet. Check now, or wait for the daily check.",
|
||||
"neverCheckedTenant": "Not checked yet. The server checks once a day.",
|
||||
"countsFail": "{{all}} to look at, {{fails}} failing",
|
||||
"counts": "{{all}} to look at",
|
||||
"nodes": "Sending addresses",
|
||||
"domains": "Domains",
|
||||
"passed": "Passed checks ({{count}})",
|
||||
"notCheckedCount": ", {{count}} not checked",
|
||||
"lists": "Blocklists asked",
|
||||
"listsWhy": "Each list sees the addresses and domains it’s asked about, as it would whenever anyone checks mail from this server.",
|
||||
"scopeIp": "addresses",
|
||||
"scopeDomain": "domains",
|
||||
"listed": "{{subject}} is on {{list}}",
|
||||
"listedWhy": "{{meaning}}. Servers that use this list refuse or file away its mail.",
|
||||
"listedWhyPlain": "Servers that use this list refuse or file away its mail.",
|
||||
"unchecked": "Couldn't check {{subject}} on {{list}}",
|
||||
"uncheckedWhy": "The list gave no usable answer.",
|
||||
"onList": "{{subject}} on {{list}}",
|
||||
"listOff": "switched off",
|
||||
"notListed": "{{subject}} isn’t on {{list}}",
|
||||
"ptrUnknown": "Couldn't look up {{ip}}'s reverse DNS",
|
||||
"noPtr": "{{ip}} has no reverse DNS",
|
||||
"noPtrWhy": "Most large mail services refuse mail from an address with no name. Your hosting provider sets it; ask for {{ehlo}}.",
|
||||
"ptrNotBack": "{{ip}}’s reverse DNS doesn’t lead back to it",
|
||||
"ptrNotBackWhy": "{{ptr}} doesn’t resolve to {{ip}}, so receivers treat the name as made up. Point {{ptr}} at {{ip}}, or change the reverse DNS.",
|
||||
"ptrNotEhlo": "{{ip}}’s reverse DNS isn’t the name it greets with",
|
||||
"ptrNotEhloWhy": "It says {{ptr}}; the server greets as {{ehlo}}. Some receivers score that as suspicious.",
|
||||
"ptrOk": "{{ip}} resolves to {{ptr}} and back",
|
||||
"spfOk": "SPF lets {{ip}} ({{node}}) send",
|
||||
"spfUnknown": "Couldn't check SPF for {{ip}}",
|
||||
"spfUnknownWhy": "The SPF lookup failed for now; it’s tried again at the next check.",
|
||||
"spfNone": "{{domain}} has no SPF record",
|
||||
"spfFail": "SPF doesn’t let {{ip}} ({{node}}) send for {{domain}}",
|
||||
"spfWhy": "Receivers check SPF on every message; mail from an address it doesn’t name is often refused. Add the address, or publish the record the domain page suggests.",
|
||||
"dkimOk": "DKIM key {{selector}} is published as signed",
|
||||
"dkimMissing": "DKIM key {{selector}} isn’t in DNS",
|
||||
"dkimDifferent": "DNS has a different key for DKIM selector {{selector}}",
|
||||
"dkimUnknown": "Couldn't check DKIM key {{selector}}",
|
||||
"dkimWhy": "Every message is signed with this key; while DNS doesn’t have it, the signature fails at the receiver. Publish the record from the domain page.",
|
||||
"dmarcOk": "DMARC aligns",
|
||||
"dmarcFail": "Mail from {{domain}} doesn’t pass DMARC",
|
||||
"dmarcWhy": "Neither a DKIM key in DNS nor SPF vouches for every address, so receivers apply the domain’s DMARC policy ({{policy}}).",
|
||||
"stsFetch": "{{domain}}’s MTA-STS policy can’t be fetched",
|
||||
"stsFetchWhy": "The DNS record says there is one, so senders that enforce MTA-STS may hold mail for {{domain}}. {{error}}",
|
||||
"stsMx": "{{domain}}’s MTA-STS policy leaves out {{mx}}",
|
||||
"stsMxWhy": "Senders that enforce the policy won’t deliver to an MX it doesn’t name. Add it to the policy’s MX hosts.",
|
||||
"stsTesting": "{{domain}}’s MTA-STS policy is in testing mode",
|
||||
"stsTestingWhy": "Senders report problems but still deliver without TLS if it fails. Switch to enforce once the reports are clean.",
|
||||
"stsOk": "MTA-STS policy covers every MX",
|
||||
"noTlsRpt": "{{domain}} has no TLS reporting record",
|
||||
"noTlsRptWhy": "With MTA-STS in place, failed TLS deliveries go unreported. Publish the TLS-RPT record from the domain page.",
|
||||
"tlsRptOk": "TLS reporting is on",
|
||||
"certOk": "Certificate for {{name}}",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -115,6 +115,10 @@ function checkSpecialLink(
|
||||
if (viewName === 'CustomComponent/SharedMailboxes') {
|
||||
return { visible: canGet ? canGet('sysAccountLock') : true, enterprise: false };
|
||||
}
|
||||
// inbuxa: Domains › Deliverability, for whoever may read the reports (deliverability spec)
|
||||
if (viewName === 'CustomComponent/Deliverability') {
|
||||
return { visible: canGet ? canGet('sysDeliverability') : true, enterprise: false };
|
||||
}
|
||||
|
||||
// INBUXA: a page this console can't draw is hidden. A server newer than the
|
||||
// console lists pages the console doesn't know yet, and counting them as
|
||||
|
||||
Reference in new issue
Block a user