From 8fdae4a32ba8858739be55b8929208f380aee87b Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 28 Sep 2026 12:53:06 -0700 Subject: [PATCH] Limits as sentences, with presets for who uses the server A stock server has no rate limits: one stolen password can send without end. A guide ("Guided or manual?" each time) asks who uses the server (me and my family, a company or school, open to the public) and offers five rules as sentences with numbers you can change: - each outside server may connect N times a minute on port 25; - each signed-in person may send N messages an hour; - each signed-in person may address N recipients an hour in total (keyed by account alone; the condition reads rcpt, so the server checks it at RCPT TO); - at most N deliveries a minute to any one receiving server; - each sender may have at most N messages waiting. Rules it writes are marked "Preset:" so running it again edits them; it never touches rules made by hand. Above the incoming, outgoing and quota lists, every rule now reads as a sentence, and an empty list says that nothing is limited. --- src/components/layout/MainContent.tsx | 18 ++ src/features/limits/LimitsSetupPage.tsx | 363 ++++++++++++++++++++++++ src/features/limits/LimitsSummary.tsx | 85 ++++++ src/features/limits/rules.test.ts | 76 +++++ src/features/limits/rules.ts | 218 ++++++++++++++ src/lib/layout.ts | 6 + src/pages/AdminPanel.tsx | 1 + 7 files changed, 767 insertions(+) create mode 100644 src/features/limits/LimitsSetupPage.tsx create mode 100644 src/features/limits/LimitsSummary.tsx create mode 100644 src/features/limits/rules.test.ts create mode 100644 src/features/limits/rules.ts diff --git a/src/components/layout/MainContent.tsx b/src/components/layout/MainContent.tsx index b5cb0bd..dee05dc 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 { 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 ; } + // inbuxa: guided setup, sending and receiving limits (settings-reorg). + if (wizard === 'limits') { + return ; + } // inbuxa: guided setup, connect a sign-in directory (settings-reorg). if (wizard === 'directory') { return ; @@ -321,5 +331,13 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti ); } + if (viewName === 'x:MtaInboundThrottle' || viewName === 'x:MtaOutboundThrottle' || viewName === 'x:MtaQueueQuota') { + return ( +
+ + +
+ ); + } return ; } diff --git a/src/features/limits/LimitsSetupPage.tsx b/src/features/limits/LimitsSetupPage.tsx new file mode 100644 index 0000000..98f7e71 --- /dev/null +++ b/src/features/limits/LimitsSetupPage.tsx @@ -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; + +async function loadRules(): Promise { + 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(null); + const [step, setStep] = useState(0); + const [profile, setProfile] = useState(null); + const [values, setValues] = useState>({}); + const [included, setIncluded] = useState>(new Set(PRESET_RULES.map((r) => r.id))); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(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> = {}; + const update: Record> = {}; + 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 ?
{error}
: ; + + 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 = { + 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 ( + setStep(1)} + aside={ + +

+ {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.')} +

+
+ } + > +
+ {PROFILES.map((p) => ( + + ))} +
+
+ ); + } + + // ── Step 2: adjust ── + if (step === 1) { + return ( + setStep(0)} + onNext={() => setStep(2)} + canNext={valid} + aside={ + +

+ {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.', + )} +

+
+ } + > +
    + {PRESET_RULES.map((rule) => { + const existing = findPreset(rule, saved); + const on = included.has(rule.id); + const n = Number(values[rule.id]); + return ( +
  • + + setIncluded((s) => { + const next = new Set(s); + if (v) next.add(rule.id); + else next.delete(rule.id); + return next; + }) + } + className="mt-1" + /> +
    +

    + {rule.sentence(Number.isInteger(n) && n > 0 ? n : rule.values[profile ?? 'personal'])} +

    +
    + setValues((v) => ({ ...v, [rule.id]: e.target.value }))} + className="h-8 w-28" + aria-label={rule.name} + /> + {existing && ( + + {t('limitsSetup.now', 'Now: {{value}}', { value: currentValue(rule, existing) ?? '—' })} + + )} +
    +
    +
  • + ); + })} +
+
+ ); + } + + // ── Step 3: review ── + if (step === 2) { + const changes = plan.filter((p) => p.action !== 'keep'); + return ( + 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={ + +

+ {t( + 'limitsSetup.undo', + 'Run the guide again, or switch any rule off in its list. Rules the guide wrote start with “Preset:”.', + )} +

+
+ } + > + {changes.length === 0 ? ( +

{t('limitsSetup.nothing', 'These limits are already in place. Nothing changes.')}

+ ) : ( + <> +

{t('limitsSetup.willChange', 'What will change')}

+
    + {changes.map((p) => ( +
  • + {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 })} +
  • + ))} +
+ + )} + {error && ( +

+ + {error} +

+ )} +
+ ); + } + + // ── Step 4: done ── + return ( + navigate(`/${section}/x:MtaInboundThrottle`)} + nextLabel={t('wizard.finish', 'Finish')} + > +

+ + {t('limitsSetup.done', 'The limits are in place.')} +

+

+ {t('limitsSetup.doneHint', 'Each list shows its rules in plain words:')}{' '} + + {t('limitsSetup.inbound', 'incoming')} + + {', '} + + {t('limitsSetup.outbound', 'outgoing')} + + {', '} + + {t('limitsSetup.quotas', 'queue quotas')} + + . +

+
+ ); +} diff --git a/src/features/limits/LimitsSummary.tsx b/src/features/limits/LimitsSummary.tsx new file mode 100644 index 0000000..c3ea586 --- /dev/null +++ b/src/features/limits/LimitsSummary.tsx @@ -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(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 ( +
+
+ +

+ {rules.length === 0 + ? t('limitsSummary.none', 'No limits here yet: nothing is slowed down or refused.') + : t('limitsSummary.title', 'In plain words')} +

+ +
+ {rules.length > 0 && ( +
    + {rules.map((r) => ( +
  • + {describe(object, r)} +
  • + ))} +
+ )} + navigate(`/${section}/${LIMITS_WIZARD_VIEW}`)} + onManual={() => undefined} + /> +
+ ); +} diff --git a/src/features/limits/rules.test.ts b/src/features/limits/rules.test.ts new file mode 100644 index 0000000..bd0883c --- /dev/null +++ b/src/features/limits/rules.test.ts @@ -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.'); + }); +}); diff --git a/src/features/limits/rules.ts b/src/features/limits/rules.ts new file mode 100644 index 0000000..f273f9a --- /dev/null +++ b/src/features/limits/rules.ts @@ -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; else: string }; + +export interface RuleRecord { + id?: string; + description?: string | null; + enable?: boolean; + key?: Record; + match?: Partial | null; + rate?: { count: number; period: number } | null; + messages?: number | null; + size?: number | null; +} + +const KEY_WORDS: Record = { + 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; + /** Everything but the number. */ + build: (n: number) => RuleRecord; + sentence: (n: number) => string; +} + +const onPort25: Partial = { match: { '0': { if: 'local_port == 25', then: 'true' } }, else: 'false' }; +const signedIn: Partial = { match: {}, else: '!is_empty(authenticated_as)' }; +const signedInRcpt: Partial = { 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): 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; +} diff --git a/src/lib/layout.ts b/src/lib/layout.ts index afb2533..f2f32af 100644 --- a/src/lib/layout.ts +++ b/src/lib/layout.ts @@ -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 }; } diff --git a/src/pages/AdminPanel.tsx b/src/pages/AdminPanel.tsx index bc0c7be..322403f 100644 --- a/src/pages/AdminPanel.tsx +++ b/src/pages/AdminPanel.tsx @@ -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}`; -- 2.54.0