Limits as sentences, with presets for who uses the server #42
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 { LimitsSummary } from '@/features/limits/LimitsSummary';
|
||||
import { SenderChecksPage } from '@/features/senderChecks/SenderChecksPage';
|
||||
import { DirectorySetupCard } from '@/features/directory/DirectorySetupCard';
|
||||
import { SendingSetupCard } from '@/features/sending/SendingSetupCard';
|
||||
@@ -83,6 +84,11 @@ const DirectorySetupPage = lazyFeature(
|
||||
() => import('@/features/directory/DirectorySetupPage'),
|
||||
(m) => m.DirectorySetupPage,
|
||||
);
|
||||
// inbuxa: guided setup, sending and receiving limits (settings-reorg spec).
|
||||
const LimitsSetupPage = lazyFeature(
|
||||
() => import('@/features/limits/LimitsSetupPage'),
|
||||
(m) => m.LimitsSetupPage,
|
||||
);
|
||||
// inbuxa: Settings › Spam filter › Local AI (ai-spam-classification spec).
|
||||
const LocalAiPage = lazyFeature(
|
||||
() => import('@/features/ai/LocalAiPage'),
|
||||
@@ -148,6 +154,10 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti
|
||||
if (wizard === 'dns' && param) {
|
||||
return <ConnectDnsPage domainId={param} />;
|
||||
}
|
||||
// inbuxa: guided setup, sending and receiving limits (settings-reorg).
|
||||
if (wizard === 'limits') {
|
||||
return <LimitsSetupPage />;
|
||||
}
|
||||
// inbuxa: guided setup, connect a sign-in directory (settings-reorg).
|
||||
if (wizard === 'directory') {
|
||||
return <DirectorySetupPage />;
|
||||
@@ -321,5 +331,13 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (viewName === 'x:MtaInboundThrottle' || viewName === 'x:MtaOutboundThrottle' || viewName === 'x:MtaQueueQuota') {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<LimitsSummary object={viewName} />
|
||||
<DynamicList viewName={viewName} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <DynamicList viewName={viewName} />;
|
||||
}
|
||||
@@ -0,0 +1,363 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* inbuxa: guided setup, "Sending and receiving limits" (settings-reorg,
|
||||
* second wave). "Who uses this server?" picks starting numbers for five
|
||||
* rules, each shown as a sentence you can adjust; the guide writes them as
|
||||
* marked presets and never touches rules made by hand.
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AlertTriangle, CheckCircle2 } from 'lucide-react';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { LoadingFallback } from '@/components/common/LoadingFallback';
|
||||
import { WizardNote, WizardShell, type WizardStep } from '@/components/wizard/WizardShell';
|
||||
import { getAccountId, jmapRequest, jmapSet } from '@/services/jmap/client';
|
||||
import { useSchemaStore } from '@/stores/schemaStore';
|
||||
import type { JmapSetResponse } from '@/types/jmap';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
currentValue,
|
||||
findPreset,
|
||||
PRESET_MARK,
|
||||
PRESET_RULES,
|
||||
presetRecord,
|
||||
PROFILES,
|
||||
type LimitObject,
|
||||
type Profile,
|
||||
type RuleRecord,
|
||||
} from './rules';
|
||||
|
||||
const OBJECTS: LimitObject[] = ['x:MtaInboundThrottle', 'x:MtaOutboundThrottle', 'x:MtaQueueQuota'];
|
||||
|
||||
type Saved = Record<LimitObject, RuleRecord[]>;
|
||||
|
||||
async function loadRules(): Promise<Saved> {
|
||||
const res = await jmapRequest(
|
||||
OBJECTS.map((o, i) => [`${o}/get`, { accountId: getAccountId(o), ids: null }, String(i)]),
|
||||
);
|
||||
const out = {} as Saved;
|
||||
OBJECTS.forEach((o, i) => {
|
||||
out[o] = ((res.find(([, , id]) => id === String(i))?.[1] as { list?: RuleRecord[] })?.list ?? []) as RuleRecord[];
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
export function LimitsSetupPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const viewToSection = useSchemaStore((s) => s.viewToSection);
|
||||
const section = viewToSection['x:MtaInboundThrottle'] ?? 'Settings';
|
||||
|
||||
const [saved, setSaved] = useState<Saved | null>(null);
|
||||
const [step, setStep] = useState(0);
|
||||
const [profile, setProfile] = useState<Profile | null>(null);
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
const [included, setIncluded] = useState<Set<string>>(new Set(PRESET_RULES.map((r) => r.id)));
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
loadRules()
|
||||
.then((s) => live && setSaved(s))
|
||||
.catch((e: unknown) => live && setError(e instanceof Error ? e.message : String(e)));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const steps: WizardStep[] = useMemo(
|
||||
() => [
|
||||
{ id: 'who', title: t('limitsSetup.stepWho', 'Who uses it') },
|
||||
{ id: 'adjust', title: t('limitsSetup.stepAdjust', 'Limits') },
|
||||
{ id: 'review', title: t('limitsSetup.stepReview', 'Review') },
|
||||
{ id: 'done', title: t('limitsSetup.stepDone', 'Done') },
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
const choose = (p: Profile) => {
|
||||
setProfile(p);
|
||||
setValues(Object.fromEntries(PRESET_RULES.map((r) => [r.id, String(r.values[p])])));
|
||||
};
|
||||
|
||||
const plan = useMemo(() => {
|
||||
if (!saved) return [];
|
||||
return PRESET_RULES.map((rule) => {
|
||||
const existing = findPreset(rule, saved);
|
||||
const n = Number(values[rule.id]);
|
||||
const on = included.has(rule.id) && Number.isInteger(n) && n > 0;
|
||||
if (on && !existing) return { rule, action: 'add' as const, n };
|
||||
if (on && existing && (currentValue(rule, existing) !== n || existing.enable === false))
|
||||
return { rule, action: 'update' as const, n, id: existing.id! };
|
||||
if (!on && existing) return { rule, action: 'remove' as const, n, id: existing.id! };
|
||||
return { rule, action: 'keep' as const, n };
|
||||
});
|
||||
}, [saved, values, included]);
|
||||
|
||||
const handMade = saved
|
||||
? OBJECTS.reduce((sum, o) => sum + saved[o].filter((r) => !(r.description ?? '').startsWith(PRESET_MARK)).length, 0)
|
||||
: 0;
|
||||
const valid = PRESET_RULES.every(
|
||||
(r) => !included.has(r.id) || (Number.isInteger(Number(values[r.id])) && Number(values[r.id]) > 0),
|
||||
);
|
||||
|
||||
const apply = async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const failed: string[] = [];
|
||||
for (const object of OBJECTS) {
|
||||
const mine = plan.filter((p) => p.rule.object === object && p.action !== 'keep');
|
||||
if (mine.length === 0) continue;
|
||||
const create: Record<string, Record<string, unknown>> = {};
|
||||
const update: Record<string, Record<string, unknown>> = {};
|
||||
const destroy: string[] = [];
|
||||
for (const p of mine) {
|
||||
if (p.action === 'add') create[p.rule.id] = { ...presetRecord(p.rule, p.n) };
|
||||
if (p.action === 'update') update[p.id] = { ...presetRecord(p.rule, p.n) };
|
||||
if (p.action === 'remove') destroy.push(p.id);
|
||||
}
|
||||
try {
|
||||
const [res] = await jmapSet(object, getAccountId(object), { create, update, destroy });
|
||||
const body = res?.[1] as unknown as JmapSetResponse | undefined;
|
||||
for (const bad of [body?.notCreated, body?.notUpdated, body?.notDestroyed]) {
|
||||
for (const e of Object.values(bad ?? {})) failed.push(e.description ?? e.type);
|
||||
}
|
||||
} catch (e) {
|
||||
failed.push(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
setBusy(false);
|
||||
if (failed.length > 0) {
|
||||
setError(failed.join('; '));
|
||||
setSaved(await loadRules().catch(() => saved));
|
||||
return;
|
||||
}
|
||||
setStep(3);
|
||||
};
|
||||
|
||||
if (!saved) return error ? <div className="p-8 text-center text-destructive">{error}</div> : <LoadingFallback />;
|
||||
|
||||
const common = {
|
||||
icon: 'gauge',
|
||||
title: t('limitsSetup.title', 'Sending and receiving limits'),
|
||||
subtitle: t(
|
||||
'limitsSetup.subtitle',
|
||||
'Sensible rate limits for the people who use this server. Without any, one stolen password can send as much spam as it likes.',
|
||||
),
|
||||
steps,
|
||||
current: step,
|
||||
onCancel: () => navigate(`/${section}/x:MtaInboundThrottle`),
|
||||
};
|
||||
|
||||
// ── Step 1: who ──
|
||||
if (step === 0) {
|
||||
const label: Record<Profile, [string, string]> = {
|
||||
personal: [
|
||||
t('limitsSetup.personal', 'Me and my family'),
|
||||
t('limitsSetup.personalHint', 'A handful of people you know. Low limits: nobody here sends in bulk.'),
|
||||
],
|
||||
organization: [
|
||||
t('limitsSetup.organization', 'A company or school'),
|
||||
t('limitsSetup.organizationHint', 'Tens to hundreds of known people, some of whom send a lot in a day.'),
|
||||
],
|
||||
hosting: [
|
||||
t('limitsSetup.hosting', 'Open to the public'),
|
||||
t(
|
||||
'limitsSetup.hostingHint',
|
||||
'People you don’t know sign up. Tight sending limits, because compromised accounts are the risk.',
|
||||
),
|
||||
],
|
||||
};
|
||||
return (
|
||||
<WizardShell
|
||||
{...common}
|
||||
canNext={profile !== null}
|
||||
onNext={() => setStep(1)}
|
||||
aside={
|
||||
<WizardNote title={t('limitsSetup.handTitle', 'Your own rules stay')}>
|
||||
<p>
|
||||
{handMade > 0
|
||||
? t('limitsSetup.handSome', 'The {{count}} rules made by hand are left exactly as they are.', {
|
||||
count: handMade,
|
||||
})
|
||||
: t('limitsSetup.handNone', 'Rules you add by hand later are never touched by this guide.')}
|
||||
</p>
|
||||
</WizardNote>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
{PROFILES.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
aria-pressed={profile === p}
|
||||
onClick={() => choose(p)}
|
||||
className={cn(
|
||||
'flex flex-col items-start gap-1.5 rounded-xl border p-4 text-left transition-colors hover:border-primary/60',
|
||||
profile === p ? 'border-primary bg-primary/5 ring-1 ring-primary' : 'bg-card',
|
||||
)}
|
||||
>
|
||||
<span className="font-medium">{label[p][0]}</span>
|
||||
<span className="text-xs text-muted-foreground">{label[p][1]}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</WizardShell>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Step 2: adjust ──
|
||||
if (step === 1) {
|
||||
return (
|
||||
<WizardShell
|
||||
{...common}
|
||||
onBack={() => setStep(0)}
|
||||
onNext={() => setStep(2)}
|
||||
canNext={valid}
|
||||
aside={
|
||||
<WizardNote title={t('limitsSetup.overTitle', 'When a limit is reached')}>
|
||||
<p>
|
||||
{t(
|
||||
'limitsSetup.over',
|
||||
'Incoming: the other side is told to try again later. Outgoing: the delivery waits in the queue and goes out when the rate allows. Nothing is lost.',
|
||||
)}
|
||||
</p>
|
||||
</WizardNote>
|
||||
}
|
||||
>
|
||||
<ul className="space-y-3">
|
||||
{PRESET_RULES.map((rule) => {
|
||||
const existing = findPreset(rule, saved);
|
||||
const on = included.has(rule.id);
|
||||
const n = Number(values[rule.id]);
|
||||
return (
|
||||
<li key={rule.id} className={cn('flex items-start gap-3 rounded-xl border p-4', !on && 'opacity-60')}>
|
||||
<Checkbox
|
||||
checked={on}
|
||||
aria-label={rule.name}
|
||||
onCheckedChange={(v) =>
|
||||
setIncluded((s) => {
|
||||
const next = new Set(s);
|
||||
if (v) next.add(rule.id);
|
||||
else next.delete(rule.id);
|
||||
return next;
|
||||
})
|
||||
}
|
||||
className="mt-1"
|
||||
/>
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<p className="text-sm">
|
||||
{rule.sentence(Number.isInteger(n) && n > 0 ? n : rule.values[profile ?? 'personal'])}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={values[rule.id] ?? ''}
|
||||
disabled={!on}
|
||||
onChange={(e) => setValues((v) => ({ ...v, [rule.id]: e.target.value }))}
|
||||
className="h-8 w-28"
|
||||
aria-label={rule.name}
|
||||
/>
|
||||
{existing && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('limitsSetup.now', 'Now: {{value}}', { value: currentValue(rule, existing) ?? '—' })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</WizardShell>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Step 3: review ──
|
||||
if (step === 2) {
|
||||
const changes = plan.filter((p) => p.action !== 'keep');
|
||||
return (
|
||||
<WizardShell
|
||||
{...common}
|
||||
onBack={() => setStep(1)}
|
||||
onNext={changes.length > 0 ? () => void apply() : () => setStep(3)}
|
||||
nextLabel={changes.length > 0 ? t('limitsSetup.apply', 'Save these limits') : t('wizard.next', 'Next')}
|
||||
busy={busy}
|
||||
aside={
|
||||
<WizardNote tone="undo" title={t('limitsSetup.undoTitle', 'Changing your mind')}>
|
||||
<p>
|
||||
{t(
|
||||
'limitsSetup.undo',
|
||||
'Run the guide again, or switch any rule off in its list. Rules the guide wrote start with “Preset:”.',
|
||||
)}
|
||||
</p>
|
||||
</WizardNote>
|
||||
}
|
||||
>
|
||||
{changes.length === 0 ? (
|
||||
<p className="text-sm">{t('limitsSetup.nothing', 'These limits are already in place. Nothing changes.')}</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="font-medium">{t('limitsSetup.willChange', 'What will change')}</p>
|
||||
<ul className="list-disc space-y-1 pl-5 text-sm">
|
||||
{changes.map((p) => (
|
||||
<li key={p.rule.id}>
|
||||
{p.action === 'add' && t('limitsSetup.add', 'Add: {{sentence}}', { sentence: p.rule.sentence(p.n) })}
|
||||
{p.action === 'update' &&
|
||||
t('limitsSetup.update', 'Change: {{sentence}}', { sentence: p.rule.sentence(p.n) })}
|
||||
{p.action === 'remove' &&
|
||||
t('limitsSetup.remove', 'Remove the preset rule for {{name}}.', { name: p.rule.name })}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
{error && (
|
||||
<p className="flex items-start gap-2 text-sm text-destructive">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</WizardShell>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Step 4: done ──
|
||||
return (
|
||||
<WizardShell
|
||||
{...common}
|
||||
onNext={() => navigate(`/${section}/x:MtaInboundThrottle`)}
|
||||
nextLabel={t('wizard.finish', 'Finish')}
|
||||
>
|
||||
<p className="flex items-center gap-2 font-medium">
|
||||
<CheckCircle2 className="h-5 w-5 text-emerald-600" />
|
||||
{t('limitsSetup.done', 'The limits are in place.')}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('limitsSetup.doneHint', 'Each list shows its rules in plain words:')}{' '}
|
||||
<Link className="text-primary hover:underline" to={`/${section}/x:MtaInboundThrottle`}>
|
||||
{t('limitsSetup.inbound', 'incoming')}
|
||||
</Link>
|
||||
{', '}
|
||||
<Link className="text-primary hover:underline" to={`/${section}/x:MtaOutboundThrottle`}>
|
||||
{t('limitsSetup.outbound', 'outgoing')}
|
||||
</Link>
|
||||
{', '}
|
||||
<Link className="text-primary hover:underline" to={`/${section}/x:MtaQueueQuota`}>
|
||||
{t('limitsSetup.quotas', 'queue quotas')}
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
</WizardShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* inbuxa: above each limits list, every rule in plain words, and the way
|
||||
* into the presets guide (settings-reorg, second wave). With no rules at
|
||||
* all it says so, since a server with no limits is worth knowing about.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Gauge } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { LaunchChoice } from '@/components/wizard/LaunchChoice';
|
||||
import { getAccountId, jmapGet } from '@/services/jmap/client';
|
||||
import { useSchemaStore } from '@/stores/schemaStore';
|
||||
import { describe, type LimitObject, type RuleRecord } from './rules';
|
||||
|
||||
export const LIMITS_WIZARD_VIEW = 'Wizard/limits';
|
||||
|
||||
export function LimitsSummary({ object }: { object: LimitObject }) {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const section = useSchemaStore((s) => s.viewToSection[object]) ?? 'Settings';
|
||||
const [rules, setRules] = useState<RuleRecord[] | null>(null);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
jmapGet(object, getAccountId(object), null)
|
||||
.then(([res]) => live && setRules(((res?.[1] as { list?: RuleRecord[] })?.list ?? []) as RuleRecord[]))
|
||||
.catch(() => live && setRules(null));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [object]);
|
||||
|
||||
if (rules === null) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-3 rounded-xl border bg-card p-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Gauge className="h-5 w-5 shrink-0 text-primary" />
|
||||
<p className="min-w-0 flex-1 font-medium">
|
||||
{rules.length === 0
|
||||
? t('limitsSummary.none', 'No limits here yet: nothing is slowed down or refused.')
|
||||
: t('limitsSummary.title', 'In plain words')}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant={rules.length === 0 ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
{t('limitsSummary.presets', 'Use presets')}
|
||||
</Button>
|
||||
</div>
|
||||
{rules.length > 0 && (
|
||||
<ul className="space-y-1 text-sm">
|
||||
{rules.map((r) => (
|
||||
<li key={r.id} className={r.enable === false ? 'text-muted-foreground' : undefined}>
|
||||
{describe(object, r)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<LaunchChoice
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
title={t('limitsSummary.chooseTitle', 'Sending and receiving limits')}
|
||||
guidedHint={t(
|
||||
'limitsSummary.guidedHint',
|
||||
'Say who uses this server and get five sensible limits, each shown as a sentence you can adjust.',
|
||||
)}
|
||||
manualHint={t('limitsSummary.manualHint', 'Add and edit each rule yourself, with every option.')}
|
||||
onGuided={() => navigate(`/${section}/${LIMITS_WIZARD_VIEW}`)}
|
||||
onManual={() => undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
import { describe as group, it, expect } from 'vitest';
|
||||
import { describe, findPreset, periodWords, PRESET_RULES, presetRecord, sizeWords } from './rules';
|
||||
|
||||
group('describe', () => {
|
||||
it('says an inbound rule per remote IP counts connections', () => {
|
||||
expect(describe('x:MtaInboundThrottle', { key: { remoteIp: true }, rate: { count: 20, period: 60_000 } })).toBe(
|
||||
'Each sending IP address: 20 connections a minute.',
|
||||
);
|
||||
});
|
||||
|
||||
it('says a rule keyed by account counts messages, or recipients when its condition reads rcpt', () => {
|
||||
expect(
|
||||
describe('x:MtaInboundThrottle', { key: { authenticatedAs: true }, rate: { count: 100, period: 3_600_000 } }),
|
||||
).toBe('Each signed-in account: 100 messages an hour.');
|
||||
expect(
|
||||
describe('x:MtaInboundThrottle', {
|
||||
key: { authenticatedAs: true },
|
||||
match: { match: {}, else: '!is_empty(authenticated_as) && !is_empty(rcpt)' },
|
||||
rate: { count: 500, period: 3_600_000 },
|
||||
}),
|
||||
).toBe('Each signed-in account: 500 recipients an hour, when its condition matches.');
|
||||
});
|
||||
|
||||
it('describes deliveries, quotas, the whole server and rules that are off', () => {
|
||||
expect(describe('x:MtaOutboundThrottle', { key: { mx: true }, rate: { count: 30, period: 60_000 } })).toBe(
|
||||
'Each receiving mail server: 30 deliveries a minute.',
|
||||
);
|
||||
expect(describe('x:MtaQueueQuota', { key: { sender: true }, messages: 500, size: 262_144_000 })).toBe(
|
||||
'Each sender address: at most 500 messages or 250 MB waiting in the queue.',
|
||||
);
|
||||
expect(describe('x:MtaOutboundThrottle', { rate: { count: 5, period: 1000 }, enable: false })).toBe(
|
||||
'The whole server: 5 deliveries a second. (off)',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('words', () => {
|
||||
it('names periods and sizes', () => {
|
||||
expect(periodWords(3_600_000)).toBe('an hour');
|
||||
expect(periodWords(300_000)).toBe('every 5 minutes');
|
||||
expect(sizeWords(1024 ** 3)).toBe('1 GB');
|
||||
});
|
||||
});
|
||||
|
||||
group('presets', () => {
|
||||
it('mark what they write, so the guide finds them again', () => {
|
||||
const rule = PRESET_RULES[0];
|
||||
const record = presetRecord(rule, 20);
|
||||
expect(record.description).toBe('Preset: connections per sending server');
|
||||
const saved = {
|
||||
'x:MtaInboundThrottle': [{ ...record, id: 'a' }],
|
||||
'x:MtaOutboundThrottle': [],
|
||||
'x:MtaQueueQuota': [],
|
||||
};
|
||||
expect(findPreset(rule, saved)?.id).toBe('a');
|
||||
expect(findPreset(PRESET_RULES[1], saved)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('read the same in the list as in the guide', () => {
|
||||
for (const rule of PRESET_RULES) {
|
||||
expect(describe(rule.object, presetRecord(rule, 10))).toBe(rule.sentence(10));
|
||||
}
|
||||
});
|
||||
|
||||
it('treat an empty condition as always', () => {
|
||||
expect(
|
||||
describe('x:MtaQueueQuota', { key: { rcptDomain: true }, match: { match: {}, else: '' }, messages: 10 }),
|
||||
).toBe('Each recipient domain: at most 10 messages waiting in the queue.');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2026 Coffey Labs
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
/**
|
||||
* inbuxa: sending and receiving limits as sentences, and presets for them
|
||||
* (settings-reorg, second wave). A stock server has no limits at all.
|
||||
*
|
||||
* How the server applies them (common/src/config/smtp/queue.rs):
|
||||
* - an inbound rule is checked when a server connects, at MAIL FROM, or at
|
||||
* RCPT TO, depending on its keys (remote IP → connection; sender or
|
||||
* signed-in account → each message; recipient → each recipient);
|
||||
* - an outbound rule is checked per delivery attempt, keyed by the
|
||||
* receiving server, sender or recipient domain; going over defers;
|
||||
* - a queue quota caps what one sender or recipient may have waiting.
|
||||
*/
|
||||
|
||||
export type LimitObject = 'x:MtaInboundThrottle' | 'x:MtaOutboundThrottle' | 'x:MtaQueueQuota';
|
||||
|
||||
export type Expr = { match: Record<string, { if: string; then: string }>; else: string };
|
||||
|
||||
export interface RuleRecord {
|
||||
id?: string;
|
||||
description?: string | null;
|
||||
enable?: boolean;
|
||||
key?: Record<string, boolean>;
|
||||
match?: Partial<Expr> | null;
|
||||
rate?: { count: number; period: number } | null;
|
||||
messages?: number | null;
|
||||
size?: number | null;
|
||||
}
|
||||
|
||||
const KEY_WORDS: Record<string, string> = {
|
||||
remoteIp: 'sending IP address',
|
||||
localIp: 'local IP address',
|
||||
listener: 'listener',
|
||||
authenticatedAs: 'signed-in account',
|
||||
heloDomain: 'greeting name',
|
||||
sender: 'sender address',
|
||||
senderDomain: 'sender domain',
|
||||
rcpt: 'recipient',
|
||||
rcptDomain: 'recipient domain',
|
||||
mx: 'receiving mail server',
|
||||
};
|
||||
|
||||
const MINUTE = 60_000;
|
||||
const HOUR = 60 * MINUTE;
|
||||
const DAY = 24 * HOUR;
|
||||
|
||||
export function periodWords(ms: number): string {
|
||||
if (ms === MINUTE) return 'a minute';
|
||||
if (ms === HOUR) return 'an hour';
|
||||
if (ms === DAY) return 'a day';
|
||||
if (ms === 1000) return 'a second';
|
||||
if (ms % DAY === 0) return `every ${ms / DAY} days`;
|
||||
if (ms % HOUR === 0) return `every ${ms / HOUR} hours`;
|
||||
if (ms % MINUTE === 0) return `every ${ms / MINUTE} minutes`;
|
||||
return `every ${Math.round(ms / 1000)} seconds`;
|
||||
}
|
||||
|
||||
export function sizeWords(bytes: number): string {
|
||||
if (bytes >= 1024 ** 3 && bytes % 1024 ** 3 === 0) return `${bytes / 1024 ** 3} GB`;
|
||||
if (bytes >= 1024 ** 2) return `${Math.round(bytes / 1024 ** 2)} MB`;
|
||||
return `${Math.round(bytes / 1024)} KB`;
|
||||
}
|
||||
|
||||
function keysOf(r: RuleRecord): string[] {
|
||||
return Object.entries(r.key ?? {})
|
||||
.filter(([, on]) => on)
|
||||
.map(([k]) => k);
|
||||
}
|
||||
|
||||
function perWhat(keys: string[]): string {
|
||||
if (keys.length === 0) return 'The whole server';
|
||||
return `Each ${keys.map((k) => KEY_WORDS[k] ?? k).join(' and ')}`;
|
||||
}
|
||||
|
||||
/** A condition that reads the recipient makes the server check the rule once per recipient. */
|
||||
function mentionsRcpt(r: RuleRecord): boolean {
|
||||
const m = r.match;
|
||||
const text = [m?.else ?? '', ...Object.values(m?.match ?? {}).map((x) => `${x.if} ${x.then}`)].join(' ');
|
||||
return /\brcpt(_domain)?\b/.test(text);
|
||||
}
|
||||
|
||||
function conditional(r: RuleRecord): boolean {
|
||||
const m = r.match;
|
||||
if (!m) return false;
|
||||
const rules = Object.values(m.match ?? {});
|
||||
const otherwise = (m.else ?? '').trim();
|
||||
// No condition, an empty one or plain "true" all mean the rule always applies.
|
||||
return rules.length > 0 || (otherwise !== '' && otherwise !== 'true');
|
||||
}
|
||||
|
||||
/**
|
||||
* A rule as a sentence: "Each sending IP address: 20 connections a minute."
|
||||
* A rule the presets wrote reads as the guide worded it.
|
||||
*/
|
||||
export function describe(object: LimitObject, r: RuleRecord): string {
|
||||
const preset = PRESET_RULES.find((p) => p.object === object && r.description === `${PRESET_MARK}${p.name}`);
|
||||
const n = preset ? (preset.unit === 'messages' ? r.messages : r.rate?.count) : null;
|
||||
if (preset && n) return preset.sentence(n) + (r.enable === false ? ' (off)' : '');
|
||||
return describeRule(object, r);
|
||||
}
|
||||
|
||||
function describeRule(object: LimitObject, r: RuleRecord): string {
|
||||
const keys = keysOf(r);
|
||||
const who = perWhat(keys);
|
||||
const when = conditional(r) ? ', when its condition matches' : '';
|
||||
const off = r.enable === false ? ' (off)' : '';
|
||||
if (object === 'x:MtaQueueQuota') {
|
||||
const caps = [r.messages ? `${r.messages} messages` : null, r.size ? sizeWords(r.size) : null].filter(Boolean);
|
||||
return `${who}: at most ${caps.join(' or ') || 'no limit'} waiting in the queue${when}.${off}`;
|
||||
}
|
||||
const count = r.rate?.count ?? 0;
|
||||
const period = periodWords(r.rate?.period ?? MINUTE);
|
||||
let what: string;
|
||||
if (object === 'x:MtaOutboundThrottle') what = 'deliveries';
|
||||
else if (keys.some((k) => k === 'rcpt' || k === 'rcptDomain') || mentionsRcpt(r)) what = 'recipients';
|
||||
else if (keys.some((k) => k === 'sender' || k === 'senderDomain' || k === 'authenticatedAs' || k === 'heloDomain'))
|
||||
what = 'messages';
|
||||
else what = 'connections';
|
||||
return `${who}: ${count} ${what} ${period}${when}.${off}`;
|
||||
}
|
||||
|
||||
// ── Presets ──
|
||||
|
||||
export type Profile = 'personal' | 'organization' | 'hosting';
|
||||
export const PROFILES: Profile[] = ['personal', 'organization', 'hosting'];
|
||||
|
||||
/** Preset rules carry this at the start of their description, so running the guide again finds them. */
|
||||
export const PRESET_MARK = 'Preset: ';
|
||||
|
||||
export interface PresetRule {
|
||||
id: string;
|
||||
object: LimitObject;
|
||||
/** The description written to the rule, after the mark. */
|
||||
name: string;
|
||||
/** Which number the person can adjust, and its unit. */
|
||||
unit: 'count' | 'messages';
|
||||
values: Record<Profile, number>;
|
||||
/** Everything but the number. */
|
||||
build: (n: number) => RuleRecord;
|
||||
sentence: (n: number) => string;
|
||||
}
|
||||
|
||||
const onPort25: Partial<Expr> = { match: { '0': { if: 'local_port == 25', then: 'true' } }, else: 'false' };
|
||||
const signedIn: Partial<Expr> = { match: {}, else: '!is_empty(authenticated_as)' };
|
||||
const signedInRcpt: Partial<Expr> = { match: {}, else: '!is_empty(authenticated_as) && !is_empty(rcpt)' };
|
||||
|
||||
export const PRESET_RULES: PresetRule[] = [
|
||||
{
|
||||
id: 'connections',
|
||||
object: 'x:MtaInboundThrottle',
|
||||
name: 'connections per sending server',
|
||||
unit: 'count',
|
||||
values: { personal: 20, organization: 60, hosting: 300 },
|
||||
build: (n) => ({ key: { remoteIp: true }, match: onPort25, rate: { count: n, period: MINUTE } }),
|
||||
sentence: (n) => `Each outside server may connect ${n} times a minute on port 25.`,
|
||||
},
|
||||
{
|
||||
id: 'sending',
|
||||
object: 'x:MtaInboundThrottle',
|
||||
name: 'messages per signed-in account',
|
||||
unit: 'count',
|
||||
values: { personal: 100, organization: 500, hosting: 100 },
|
||||
build: (n) => ({ key: { authenticatedAs: true }, match: signedIn, rate: { count: n, period: HOUR } }),
|
||||
sentence: (n) => `Each signed-in person may send ${n} messages an hour. Caps the damage from a stolen password.`,
|
||||
},
|
||||
{
|
||||
id: 'recipients',
|
||||
object: 'x:MtaInboundThrottle',
|
||||
name: 'recipients per signed-in account',
|
||||
unit: 'count',
|
||||
values: { personal: 500, organization: 2000, hosting: 500 },
|
||||
// Keyed by the account alone, so every recipient counts toward one total; the
|
||||
// condition names rcpt, which is what makes the server check it at RCPT TO.
|
||||
build: (n) => ({ key: { authenticatedAs: true }, match: signedInRcpt, rate: { count: n, period: HOUR } }),
|
||||
sentence: (n) => `Each signed-in person may address ${n} recipients an hour, across all their messages.`,
|
||||
},
|
||||
{
|
||||
id: 'per-mx',
|
||||
object: 'x:MtaOutboundThrottle',
|
||||
name: 'deliveries per receiving server',
|
||||
unit: 'count',
|
||||
values: { personal: 30, organization: 60, hosting: 120 },
|
||||
build: (n) => ({ key: { mx: true }, rate: { count: n, period: MINUTE } }),
|
||||
sentence: (n) =>
|
||||
`At most ${n} deliveries a minute to any one receiving server, so big providers don’t see a burst and slow you down.`,
|
||||
},
|
||||
{
|
||||
id: 'queue',
|
||||
object: 'x:MtaQueueQuota',
|
||||
name: 'waiting messages per sender',
|
||||
unit: 'messages',
|
||||
values: { personal: 500, organization: 2000, hosting: 500 },
|
||||
build: (n) => ({ key: { sender: true }, messages: n, size: n * 512 * 1024 }),
|
||||
sentence: (n) =>
|
||||
`Each sender may have at most ${n} messages (about ${sizeWords(n * 512 * 1024)}) waiting to be delivered.`,
|
||||
},
|
||||
];
|
||||
|
||||
/** The record a preset rule writes. */
|
||||
export function presetRecord(rule: PresetRule, n: number): RuleRecord {
|
||||
return { description: `${PRESET_MARK}${rule.name}`, enable: true, ...rule.build(n) };
|
||||
}
|
||||
|
||||
/** Which saved rule, if any, a preset rule wrote before. */
|
||||
export function findPreset(rule: PresetRule, saved: Record<LimitObject, RuleRecord[]>): RuleRecord | undefined {
|
||||
return saved[rule.object].find((r) => r.description === `${PRESET_MARK}${rule.name}`);
|
||||
}
|
||||
|
||||
/** The number a saved preset rule holds now. */
|
||||
export function currentValue(rule: PresetRule, saved: RuleRecord): number | null {
|
||||
if (rule.unit === 'messages') return saved.messages ?? null;
|
||||
return saved.rate?.count ?? null;
|
||||
}
|
||||
@@ -112,6 +112,12 @@ function checkSpecialLink(
|
||||
const ok = canGet ? canGet('sysDirectory') && canGet('sysDomain') && canGet('sysAuthentication') : true;
|
||||
return { visible: ok, enterprise: false };
|
||||
}
|
||||
if (viewName === 'Wizard/limits') {
|
||||
const ok = canGet
|
||||
? canGet('sysMtaInboundThrottle') && canGet('sysMtaOutboundThrottle') && canGet('sysMtaQueueQuota')
|
||||
: true;
|
||||
return { visible: ok, enterprise: false };
|
||||
}
|
||||
if (viewName.startsWith('Wizard/dns/')) {
|
||||
return { visible: canGet ? canGet('sysDomain') : true, enterprise: false };
|
||||
}
|
||||
|
||||
@@ -106,6 +106,7 @@ export default function AdminPanel() {
|
||||
if (!section) return t('dashboard.title', 'Dashboard');
|
||||
if (!viewName) return section;
|
||||
if (viewName.startsWith('Wizard/dns/')) return `${t('dnsWizard.tabTitle', 'Publish DNS')} · ${section}`;
|
||||
if (viewName === 'Wizard/limits') return `${t('limitsSetup.tabTitle', 'Limits')} · ${section}`;
|
||||
if (viewName === 'Wizard/directory') return `${t('dirSetup.tabTitle', 'Sign-in directory')} · ${section}`;
|
||||
if (viewName === 'Wizard/certificates') return `${t('certSetup.tabTitle', 'Certificates')} · ${section}`;
|
||||
if (viewName === 'Wizard/sending') return `${t('sendingSetup.tabTitle', 'Sending mail')} · ${section}`;
|
||||
|
||||
Reference in new issue
Block a user