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