(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;
+}
diff --git a/src/features/hovercards/ObjectHoverCard.tsx b/src/features/hovercards/ObjectHoverCard.tsx
index bddebe3..e32ca74 100644
--- a/src/features/hovercards/ObjectHoverCard.tsx
+++ b/src/features/hovercards/ObjectHoverCard.tsx
@@ -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 }) {
)}
+ {f.deliverability && (
+
+
+ {f.deliverability}
+
+ )}
{auto(f.dns)}
{auto(f.dkim)}
diff --git a/src/features/hovercards/facts.ts b/src/features/hovercards/facts.ts
index 9f3aa9b..b03aadd 100644
--- a/src/features/hovercards/facts.ts
+++ b/src/features/hovercards/facts.ts
@@ -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 {
+ 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;
+ return worstFor(grade(reports, settings.lists, t), domain.toLowerCase())?.title;
+}
+
async function domainFacts(id: string): Promise {
const accountId = getAccountId('x:Domain');
const responses = await jmapRequest([
@@ -80,9 +94,10 @@ async function domainFacts(id: string): Promise {
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 {
dkim: mode(d.dkimManagement),
certs: mode(d.certificateManagement),
checks,
+ deliverability,
};
}
diff --git a/src/i18n/en.json b/src/i18n/en.json
index cdbf35a..bdd5ac5 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -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"
}
}
diff --git a/src/lib/layout.ts b/src/lib/layout.ts
index bcdaff4..535b044 100644
--- a/src/lib/layout.ts
+++ b/src/lib/layout.ts
@@ -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