From 86ae2c6672160ad76b16e65b6ca4d11d852ea2b4 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 28 Sep 2026 12:26:47 -0700 Subject: [PATCH] Sender checks as a level: Relaxed, Recommended or Strict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mail flow › Receiving › Sender checks opens with one choice instead of six strategy fields, and a table of what each level does to mail that fails. From the SMTP code: - Relaxed (the stock settings): SPF, DKIM, DMARC and reverse DNS are checked on port 25 and scored by the spam filter; nothing is refused. - Recommended: also refuses mail that fails DMARC for a domain that publishes p=reject, and checks ARC. - Strict: also refuses senders that fail reverse DNS or SPF on MAIL FROM (including senders with none), and broken ARC chains. It warns that some real mail from small or badly set-up servers will bounce. No level refuses unsigned mail (strict DKIM, which RFC 6376 advises against). Checks stay on port 25, like the stock settings. Values set by hand show as "Set by hand below", and applying a level then says it replaces them. The form reloads after a level is applied; DKIM signing isn't touched. --- src/components/layout/MainContent.tsx | 5 + .../senderChecks/SenderChecksLevels.tsx | 213 ++++++++++++++++++ .../senderChecks/SenderChecksPage.tsx | 20 ++ src/features/senderChecks/levels.test.ts | 44 ++++ src/features/senderChecks/levels.ts | 100 ++++++++ 5 files changed, 382 insertions(+) create mode 100644 src/features/senderChecks/SenderChecksLevels.tsx create mode 100644 src/features/senderChecks/SenderChecksPage.tsx create mode 100644 src/features/senderChecks/levels.test.ts create mode 100644 src/features/senderChecks/levels.ts diff --git a/src/components/layout/MainContent.tsx b/src/components/layout/MainContent.tsx index a67ba07..b5cb0bd 100644 --- a/src/components/layout/MainContent.tsx +++ b/src/components/layout/MainContent.tsx @@ -17,6 +17,7 @@ import { DynamicForm } from '@/components/forms/DynamicForm'; import { DynamicViewPage } from '@/components/views/DynamicViewPage'; import { LoadingFallback } from '@/components/common/LoadingFallback'; import { LegacyProtocolsBanner } from '@/features/hardening/LegacyProtocolsBanner'; +import { SenderChecksPage } from '@/features/senderChecks/SenderChecksPage'; import { DirectorySetupCard } from '@/features/directory/DirectorySetupCard'; import { SendingSetupCard } from '@/features/sending/SendingSetupCard'; import { CertificateSetupCard } from '@/features/certificates/CertificateSetupCard'; @@ -234,6 +235,10 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti ); } + // inbuxa: Sender checks open with a choice of level (settings-reorg). + if (resolved.objectName === 'x:SenderAuth') { + return ; + } if (resolved.objectName === 'x:Security') { return (
diff --git a/src/features/senderChecks/SenderChecksLevels.tsx b/src/features/senderChecks/SenderChecksLevels.tsx new file mode 100644 index 0000000..93766eb --- /dev/null +++ b/src/features/senderChecks/SenderChecksLevels.tsx @@ -0,0 +1,213 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * inbuxa: at the top of Mail flow › Receiving › Sender checks, the six + * checks as one choice of level, with a table of what each level does to + * mail that fails (settings-reorg, first wave). The form below stays for + * anything finer; setting values by hand shows as "Set by hand". + */ + +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { AlertTriangle, Loader2, ShieldCheck } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { getAccountId, jmapGet, jmapSet } from '@/services/jmap/client'; +import { useAccountStore } from '@/stores/accountStore'; +import type { JmapSetResponse } from '@/types/jmap'; +import { cn } from '@/lib/utils'; +import { CHECKS, currentLevel, LEVELS, levelValues, modeOf, type Check, type Level } from './levels'; + +const OBJECT = 'x:SenderAuth'; + +export function SenderChecksLevels({ onApplied }: { onApplied: () => void }) { + const { t } = useTranslation(); + const canUpdate = useAccountStore((s) => s.hasObjectPermission('sysSenderAuth', 'Update')); + const [current, setCurrent] = useState(undefined); + const [picked, setPicked] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [version, setVersion] = useState(0); + + useEffect(() => { + let live = true; + jmapGet(OBJECT, getAccountId(OBJECT), ['singleton'], [...CHECKS, 'dkimStrict']) + .then(([res]) => { + const data = ((res?.[1] as { list?: Record[] })?.list ?? [])[0]; + if (!live || !data) return; + const level = currentLevel(data); + setCurrent(level); + setPicked(level ?? 'recommended'); + }) + .catch(() => live && setCurrent(null)); + return () => { + live = false; + }; + }, [version]); + + const label: Record = { + relaxed: t('senderLevels.relaxed', 'Relaxed'), + recommended: t('senderLevels.recommended', 'Recommended'), + strict: t('senderLevels.strict', 'Strict'), + }; + const hint: Record = { + relaxed: t( + 'senderLevels.relaxedHint', + 'Checks everything and hands the results to the spam filter, but refuses nothing. The server’s stock setting.', + ), + recommended: t( + 'senderLevels.recommendedHint', + 'Also refuses mail forged in the name of a domain that asks for that (DMARC p=reject), and checks forwarding chains (ARC).', + ), + strict: t( + 'senderLevels.strictHint', + 'Also refuses senders without matching reverse DNS or a passing SPF record. Expect to lose some real mail from small or badly set-up servers.', + ), + }; + const checkName: Record = { + spfEhloVerify: t('senderLevels.spfEhlo', 'Sending server’s name (SPF on EHLO)'), + spfFromVerify: t('senderLevels.spfFrom', 'Envelope sender (SPF on MAIL FROM)'), + dkimVerify: t('senderLevels.dkim', 'Signatures (DKIM)'), + dmarcVerify: t('senderLevels.dmarc', 'Sender domain’s policy (DMARC)'), + arcVerify: t('senderLevels.arc', 'Forwarding chain (ARC)'), + reverseIpVerify: t('senderLevels.iprev', 'Reverse DNS of the sending server'), + }; + const cell = (level: Level, check: Check) => { + const mode = modeOf(level, check); + if (mode === 'disable') return { text: t('senderLevels.off', 'Not checked'), tone: 'muted' }; + if (mode === 'relaxed') return { text: t('senderLevels.checked', 'Checked, scored'), tone: 'plain' }; + if (check === 'dmarcVerify') + return { text: t('senderLevels.dmarcReject', 'Refused if the domain says reject'), tone: 'strong' }; + return { text: t('senderLevels.refused', 'Failures refused'), tone: 'warn' }; + }; + + const apply = async () => { + if (!picked) return; + setBusy(true); + setError(null); + try { + const [res] = await jmapSet(OBJECT, getAccountId(OBJECT), { update: { singleton: levelValues(picked) } }); + const body = res?.[1] as unknown as JmapSetResponse | undefined; + const bad = body?.notUpdated?.singleton; + if (bad || !body?.updated || !('singleton' in body.updated)) { + throw new Error(bad?.description ?? bad?.type ?? t('senderLevels.failed', 'The level could not be saved.')); + } + setVersion((v) => v + 1); + onApplied(); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + }; + + if (current === undefined) return null; + + return ( +
+
+ +

{t('senderLevels.title', 'How strict to be with incoming mail')}

+ {current === null && ( + + {t('senderLevels.byHand', 'Set by hand below')} + + )} +
+ +
+ {LEVELS.map((l) => ( + + ))} +
+ +
+ + + + + {LEVELS.map((l) => ( + + ))} + + + + {CHECKS.map((c) => ( + + + {LEVELS.map((l) => { + const v = cell(l, c); + return ( + + ); + })} + + ))} + +
{t('senderLevels.check', 'Check')} + {label[l]} +
{checkName[c]} + {v.text} +
+
+

+ {t( + 'senderLevels.footnote', + 'Checks run on port 25, where other servers deliver; mail from your own signed-in users isn’t checked. No level refuses unsigned mail: most legitimate mail would bounce.', + )} +

+ + {canUpdate && picked && picked !== current && ( +
+ {current === null && ( +

+ + {t( + 'senderLevels.replaces', + 'This replaces the six checks below, including any conditions you added to them. DKIM signing isn’t touched.', + )} +

+ )} + +
+ )} + {error &&

{error}

} +
+ ); +} diff --git a/src/features/senderChecks/SenderChecksPage.tsx b/src/features/senderChecks/SenderChecksPage.tsx new file mode 100644 index 0000000..8413e68 --- /dev/null +++ b/src/features/senderChecks/SenderChecksPage.tsx @@ -0,0 +1,20 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { useState } from 'react'; +import { DynamicForm } from '@/components/forms/DynamicForm'; +import { SenderChecksLevels } from './SenderChecksLevels'; + +/** Sender checks: the level choice, then the full form, reloaded when a level is applied. */ +export function SenderChecksPage({ viewName }: { viewName: string }) { + const [version, setVersion] = useState(0); + return ( +
+ setVersion((v) => v + 1)} /> + +
+ ); +} diff --git a/src/features/senderChecks/levels.test.ts b/src/features/senderChecks/levels.test.ts new file mode 100644 index 0000000..5f1fbcb --- /dev/null +++ b/src/features/senderChecks/levels.test.ts @@ -0,0 +1,44 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { describe, it, expect } from 'vitest'; +import { currentLevel, levelValues } from './levels'; + +// The server's stock values (x:SenderAuth defaults). +const stock = { + arcVerify: { else: 'disable', match: {} }, + dkimStrict: true, + dkimVerify: { else: 'relaxed', match: {} }, + dmarcVerify: { else: 'disable', match: { '0': { if: 'local_port == 25', then: 'relaxed' } } }, + reverseIpVerify: { else: 'disable', match: { '0': { if: 'local_port == 25', then: 'relaxed' } } }, + spfEhloVerify: { else: 'disable', match: { '0': { if: 'local_port == 25', then: 'relaxed' } } }, + spfFromVerify: { else: 'disable', match: { '0': { if: 'local_port == 25', then: 'relaxed' } } }, +}; + +describe('levels', () => { + it('Relaxed is exactly the stock settings', () => { + expect(currentLevel(stock)).toBe('relaxed'); + expect(levelValues('relaxed')).toEqual(stock); + }); + + it('Recommended honors DMARC reject and checks ARC on port 25', () => { + const v = levelValues('recommended'); + expect(v.dmarcVerify).toEqual({ match: { '0': { if: 'local_port == 25', then: 'strict' } }, else: 'disable' }); + expect(v.arcVerify).toEqual({ match: { '0': { if: 'local_port == 25', then: 'relaxed' } }, else: 'disable' }); + expect(v.spfFromVerify).toEqual(stock.spfFromVerify); + }); + + it('no level rejects unsigned mail', () => { + for (const l of ['relaxed', 'recommended', 'strict'] as const) { + expect(levelValues(l).dkimVerify).toEqual({ match: {}, else: 'relaxed' }); + } + }); + + it('recognises each level it writes, and hand-set values as none', () => { + for (const l of ['relaxed', 'recommended', 'strict'] as const) expect(currentLevel(levelValues(l))).toBe(l); + expect(currentLevel({ ...stock, spfEhloVerify: { else: 'strict', match: {} } })).toBeNull(); + }); +}); diff --git a/src/features/senderChecks/levels.ts b/src/features/senderChecks/levels.ts new file mode 100644 index 0000000..4786c45 --- /dev/null +++ b/src/features/senderChecks/levels.ts @@ -0,0 +1,100 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * inbuxa: Sender checks as three levels (settings-reorg, first wave). What + * each setting really does, from the SMTP code: + * + * - relaxed: check, record the result for the spam filter, reject nothing; + * - strict SPF or reverse DNS: reject anything that isn't a pass, including + * a sender with no SPF record or no reverse DNS at all; + * - strict DKIM: reject every message without a passing signature, unsigned + * mail included, which RFC 6376 §6.1 advises against, so no level uses it; + * - strict DMARC: reject only when the sender's domain publishes p=reject + * and the message fails, which is what that domain asked for; + * - strict ARC: reject a broken ARC chain (no chain passes). + * + * Checks apply on port 25, where other servers deliver; mail from signed-in + * users (587/465) isn't checked, as in the stock settings. DKIM keeps its + * stock "relaxed everywhere". + */ + +export type Level = 'relaxed' | 'recommended' | 'strict'; +export const LEVELS: Level[] = ['relaxed', 'recommended', 'strict']; + +export type Expr = { match: Record; else: string }; + +export const CHECKS = [ + 'spfEhloVerify', + 'spfFromVerify', + 'dkimVerify', + 'dmarcVerify', + 'arcVerify', + 'reverseIpVerify', +] as const; +export type Check = (typeof CHECKS)[number]; + +type Mode = 'relaxed' | 'strict' | 'disable'; + +const onPort25 = (mode: Mode): Expr => ({ match: { '0': { if: 'local_port == 25', then: mode } }, else: 'disable' }); +const everywhere = (mode: Mode): Expr => ({ match: {}, else: mode }); + +const MODES: Record> = { + relaxed: { + spfEhloVerify: 'relaxed', + spfFromVerify: 'relaxed', + dkimVerify: 'relaxed', + dmarcVerify: 'relaxed', + arcVerify: 'disable', + reverseIpVerify: 'relaxed', + }, + recommended: { + spfEhloVerify: 'relaxed', + spfFromVerify: 'relaxed', + dkimVerify: 'relaxed', + dmarcVerify: 'strict', + arcVerify: 'relaxed', + reverseIpVerify: 'relaxed', + }, + strict: { + spfEhloVerify: 'relaxed', + spfFromVerify: 'strict', + dkimVerify: 'relaxed', + dmarcVerify: 'strict', + arcVerify: 'strict', + reverseIpVerify: 'strict', + }, +}; + +export function modeOf(level: Level, check: Check): Mode { + return MODES[level][check]; +} + +/** The six expressions a level writes, plus rejecting insecure DKIM signatures. */ +export function levelValues(level: Level): Record & { dkimStrict: boolean } { + const out = { dkimStrict: true } as Record & { dkimStrict: boolean }; + for (const c of CHECKS) { + const mode = MODES[level][c]; + out[c] = c === 'dkimVerify' ? everywhere(mode) : mode === 'disable' ? everywhere('disable') : onPort25(mode); + } + return out; +} + +function norm(e: unknown): string { + const x = (e ?? {}) as Partial; + const rules = Object.values(x.match ?? {}).map((r) => [r.if.replace(/\s+/g, ' ').trim(), r.then.trim()]); + return JSON.stringify([rules, (x.else ?? '').trim()]); +} + +/** Which level the current settings are, or null when they've been set by hand. */ +export function currentLevel(values: Record): Level | null { + return ( + LEVELS.find((level) => { + const want = levelValues(level); + return CHECKS.every((c) => norm(values[c]) === norm(want[c])) && values.dkimStrict !== false; + }) ?? null + ); +} -- 2.54.0