diff --git a/src/components/layout/MainContent.tsx b/src/components/layout/MainContent.tsx index 57a309d..072b7aa 100644 --- a/src/components/layout/MainContent.tsx +++ b/src/components/layout/MainContent.tsx @@ -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 ; } + if (componentName === 'Deliverability') { + return ; + } if (componentName === 'LegalHolds') { return ; } diff --git a/src/features/dashboard/attention.ts b/src/features/dashboard/attention.ts index 9734e75..42e43fa 100644 --- a/src/features/dashboard/attention.ts +++ b/src/features/dashboard/attention.ts @@ -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', diff --git a/src/features/dashboard/components/DashboardShell.tsx b/src/features/dashboard/components/DashboardShell.tsx index 83b0e27..e6bc18f 100644 --- a/src/features/dashboard/components/DashboardShell.tsx +++ b/src/features/dashboard/components/DashboardShell.tsx @@ -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, diff --git a/src/features/deliverability/DeliverabilityPage.tsx b/src/features/deliverability/DeliverabilityPage.tsx new file mode 100644 index 0000000..d902676 --- /dev/null +++ b/src/features/deliverability/DeliverabilityPage.tsx @@ -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 = { + 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; +} + +async function domainIds(): Promise> { + 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(null); + const [error, setError] = useState(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(null); + const timer = useRef | null>(null); + + const load = useCallback(async (): Promise => { + const [reports, settings, ids] = await Promise.all([ + fetchReports(), + fetchSettings(), + domainIds().catch(() => new Map()), + ]); + 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 ( + + ); + case 'domain': { + const id = loaded?.domainIds.get(fix.domain); + const section = viewToSection['x:Domain'] ?? 'Management'; + return ( + + ); + } + case 'view': + return ( + + ); + case 'elsewhere': + return ( + + {t('deliv.elsewhere', 'Set with your hosting provider or DNS host, not here.')} + + ); + } + }; + + const gradeLabel: Record = { + fail: t('deliv.fail', 'Fail'), + warn: t('deliv.warn', 'Warn'), + unknown: t('deliv.unknown', "Couldn't check"), + }; + + if (unavailable) { + return ( +
+ {t('deliv.unavailable', 'This server doesn’t have the deliverability check yet.')} +
+ ); + } + if (error && !loaded) { + return ( +
+ {error} +
+ ); + } + if (!loaded || !graded) return ; + + const { reports, settings } = loaded; + const fails = graded.findings.filter((f) => f.grade === 'fail').length; + const groups = (scope: 'node' | 'domain') => { + const map = new Map(); + 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) => ( +
  • +
    + + {gradeLabel[f.grade]} + +
    +

    {f.title}

    +

    {f.why}

    +
    +
    + {fixLink(f.fix)} +
  • + ); + + const section = (title: string, map: Map) => + map.size > 0 && ( +
    +

    {title}

    + {[...map.entries()].map(([owner, findings]) => ( +
    +

    {owner}

    +
      {findings.map(card)}
    +
    + ))} +
    + ); + + return ( +
    +
    +
    +

    {t('deliv.title', 'Deliverability')}

    + {canCheck && ( + + )} +
    +

    + {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.', + )} +

    + {reports.length > 0 && ( +

    + {reports + .map((r) => + t('deliv.checkedOn', '{{node}} checked {{when}}', { + node: r.hostname, + when: new Date(r.checkedAt).toLocaleString(), + }), + ) + .join(' · ')} +

    + )} +
    + + {reports.length === 0 && ( +

    + {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.')} +

    + )} + + {graded.findings.length > 0 && ( +

    + {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 })} +

    + )} + + {section(t('deliv.nodes', 'Sending addresses'), nodeGroups)} + {section(t('deliv.domains', 'Domains'), domainGroups)} + + {graded.passed.length > 0 && ( +
    + + {showPassed && ( +
      + {graded.passed.map((p) => ( +
    • + {p.owner} + {p.title} + {p.notChecked && — {p.notChecked}} +
    • + ))} +
    + )} +
    + )} + + {canChangeLists && settings.lists.length > 0 && ( +
    +

    + {t('deliv.lists', 'Blocklists asked')} +

    +

    + {t( + 'deliv.listsWhy', + 'Each list sees the addresses and domains it’s asked about, as it would whenever anyone checks mail from this server.', + )} +

    +
      + {settings.lists.map((list) => { + const on = !settings.disabledLists.includes(list.name); + return ( +
    • + void toggleList(list.name, v)} + aria-label={list.name} + /> +
      + + {list.note &&

      {list.note}

      } +
      + {savingList === list.name && } +
    • + ); + })} +
    +
    + )} +
    + ); +} diff --git a/src/features/deliverability/api.ts b/src/features/deliverability/api.ts new file mode 100644 index 0000000..aa3e5cf --- /dev/null +++ b/src/features/deliverability/api.ts @@ -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): Promise> { + 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; +} + +export async function fetchReports(): Promise { + const result = await call('inbuxa:DeliverabilityReport/get', { ids: null }); + return (result.list as Report[]) ?? []; +} + +export async function fetchSettings(): Promise { + 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 { + const result = await call('inbuxa:DeliverabilitySettings/set', { + update: { singleton: { disabledLists: names } }, + }); + const failed = (result.notUpdated as Record | 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 | undefined)?.now; + if (!created) { + const failed = (result.notCreated as Record | undefined)?.now; + throw new Error(failed?.description ?? 'Not started'); + } + return created; +} diff --git a/src/features/deliverability/grade.test.ts b/src/features/deliverability/grade.test.ts new file mode 100644 index 0000000..d3d64af --- /dev/null +++ b/src/features/deliverability/grade.test.ts @@ -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) => + 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 { + 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 { + 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 { + 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(); + }); +}); diff --git a/src/features/deliverability/grade.ts b/src/features/deliverability/grade.ts new file mode 100644 index 0000000..5519646 --- /dev/null +++ b/src/features/deliverability/grade.ts @@ -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; + +const ORDER: Record = { 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(); + out.findings = out.findings.filter((f) => (seen.has(f.key) ? false : (seen.add(f.key), true))); + const passedSeen = new Set(); + 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; +} diff --git a/src/features/deliverability/useDeliverabilityAlarms.ts b/src/features/deliverability/useDeliverabilityAlarms.ts new file mode 100644 index 0000000..ed6c538 --- /dev/null +++ b/src/features/deliverability/useDeliverabilityAlarms.ts @@ -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(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