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