Sender checks as a level: Relaxed, Recommended or Strict #40
No files matched your search
@@ -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
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// inbuxa: Sender checks open with a choice of level (settings-reorg).
|
||||
if (resolved.objectName === 'x:SenderAuth') {
|
||||
return <SenderChecksPage viewName={viewName} />;
|
||||
}
|
||||
if (resolved.objectName === 'x:Security') {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
|
||||
@@ -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<Level | null | undefined>(undefined);
|
||||
const [picked, setPicked] = useState<Level | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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<string, unknown>[] })?.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<Level, string> = {
|
||||
relaxed: t('senderLevels.relaxed', 'Relaxed'),
|
||||
recommended: t('senderLevels.recommended', 'Recommended'),
|
||||
strict: t('senderLevels.strict', 'Strict'),
|
||||
};
|
||||
const hint: Record<Level, string> = {
|
||||
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<Check, string> = {
|
||||
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 (
|
||||
<div className="mx-auto max-w-4xl space-y-4 rounded-xl border bg-card p-5">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<ShieldCheck className="h-5 w-5 text-primary" />
|
||||
<p className="font-medium">{t('senderLevels.title', 'How strict to be with incoming mail')}</p>
|
||||
{current === null && (
|
||||
<span className="rounded-full bg-muted px-2 py-0.5 text-xs text-muted-foreground">
|
||||
{t('senderLevels.byHand', 'Set by hand below')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2 sm:grid-cols-3" role="radiogroup">
|
||||
{LEVELS.map((l) => (
|
||||
<button
|
||||
key={l}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={picked === l}
|
||||
disabled={!canUpdate}
|
||||
onClick={() => setPicked(l)}
|
||||
className={cn(
|
||||
'flex flex-col items-start gap-1 rounded-lg border p-3 text-left transition-colors hover:border-primary/60',
|
||||
picked === l ? 'border-primary bg-primary/5 ring-1 ring-primary' : 'bg-background',
|
||||
)}
|
||||
>
|
||||
<span className="flex w-full items-center gap-2 text-sm font-medium">
|
||||
{label[l]}
|
||||
{current === l && (
|
||||
<span className="ml-auto text-xs font-normal text-muted-foreground">
|
||||
{t('senderLevels.now', 'Current')}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">{hint[l]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-xs text-muted-foreground">
|
||||
<th className="py-1.5 pr-3 font-medium">{t('senderLevels.check', 'Check')}</th>
|
||||
{LEVELS.map((l) => (
|
||||
<th key={l} className={cn('py-1.5 pr-3 font-medium', picked === l && 'text-foreground')}>
|
||||
{label[l]}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{CHECKS.map((c) => (
|
||||
<tr key={c}>
|
||||
<td className="py-1.5 pr-3">{checkName[c]}</td>
|
||||
{LEVELS.map((l) => {
|
||||
const v = cell(l, c);
|
||||
return (
|
||||
<td
|
||||
key={l}
|
||||
className={cn(
|
||||
'py-1.5 pr-3',
|
||||
v.tone === 'muted' && 'text-muted-foreground',
|
||||
v.tone === 'warn' && 'text-amber-600 dark:text-amber-400',
|
||||
v.tone === 'strong' && 'font-medium',
|
||||
picked !== l && 'opacity-60',
|
||||
)}
|
||||
>
|
||||
{v.text}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{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.',
|
||||
)}
|
||||
</p>
|
||||
|
||||
{canUpdate && picked && picked !== current && (
|
||||
<div className="flex flex-wrap items-center gap-3 border-t pt-4">
|
||||
{current === null && (
|
||||
<p className="flex flex-1 items-start gap-2 text-sm text-muted-foreground">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
{t(
|
||||
'senderLevels.replaces',
|
||||
'This replaces the six checks below, including any conditions you added to them. DKIM signing isn’t touched.',
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<Button type="button" className="ml-auto" onClick={() => void apply()} disabled={busy}>
|
||||
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{t('senderLevels.apply', 'Use {{level}}', { level: label[picked] })}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-4">
|
||||
<SenderChecksLevels onApplied={() => setVersion((v) => v + 1)} />
|
||||
<DynamicForm key={version} viewName={viewName} objectId="singleton" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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<string, { if: string; then: string }>; 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<Level, Record<Check, Mode>> = {
|
||||
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<Check, Expr> & { dkimStrict: boolean } {
|
||||
const out = { dkimStrict: true } as Record<Check, Expr> & { 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<Expr>;
|
||||
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<string, unknown>): Level | null {
|
||||
return (
|
||||
LEVELS.find((level) => {
|
||||
const want = levelValues(level);
|
||||
return CHECKS.every((c) => norm(values[c]) === norm(want[c])) && values.dkimStrict !== false;
|
||||
}) ?? null
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user