Limits as sentences, with presets for who uses the server #42

Merged
jcoffey-dev merged 1 commits from feature/limit-presets into main 2026-09-28 22:00:22 +00:00
7 changed files with 767 additions and 0 deletions

No files matched your search

+18
View File
@@ -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} />;
}
+363
View File
@@ -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>
);
}
+85
View File
@@ -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>
);
}
+76
View File
@@ -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.');
});
});
+218
View File
@@ -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;
}
+6
View File
@@ -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 };
}
+1
View File
@@ -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}`;