Domains › Deliverability: what other mail servers see of this one #83

Merged
jcoffey-dev merged 1 commits from feat/deliverability-page into main 2026-10-06 01:28:11 +00:00
12 changed files with 1328 additions and 5 deletions

No files matched your search

+8
View File
@@ -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 />;
}
+20
View File
@@ -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>
);
}
+125
View File
@@ -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;
}
+189
View File
@@ -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();
});
});
+413
View File
@@ -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;
}
+7 -1
View File
@@ -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>
+19 -3
View File
@@ -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
View File
@@ -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"
}
}
+4
View File
@@ -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