diff --git a/src/components/layout/MainContent.tsx b/src/components/layout/MainContent.tsx
index dee05dc..4322b81 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 { RetentionPage } from '@/features/retention/RetentionPage';
import { LimitsSummary } from '@/features/limits/LimitsSummary';
import { SenderChecksPage } from '@/features/senderChecks/SenderChecksPage';
import { DirectorySetupCard } from '@/features/directory/DirectorySetupCard';
@@ -249,6 +250,10 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti
if (resolved.objectName === 'x:SenderAuth') {
return ;
}
+ // inbuxa: every retention page opens with all the "how long"s as sentences (settings-reorg).
+ if (resolved.objectName === 'x:DataRetention') {
+ return ;
+ }
if (resolved.objectName === 'x:Security') {
return (
diff --git a/src/features/retention/RetentionPage.tsx b/src/features/retention/RetentionPage.tsx
new file mode 100644
index 0000000..9d120ce
--- /dev/null
+++ b/src/features/retention/RetentionPage.tsx
@@ -0,0 +1,36 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Coffey Labs
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { ChevronDown } from 'lucide-react';
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
+import { DynamicForm } from '@/components/forms/DynamicForm';
+import { RetentionSheet } from './RetentionSheet';
+
+/**
+ * A retention page: every "how long" as sentences, then this page's own
+ * settings (schedules and the like) folded underneath, reloaded after the
+ * sentences are saved so the two never disagree.
+ */
+export function RetentionPage({ viewName }: { viewName: string }) {
+ const { t } = useTranslation();
+ const [version, setVersion] = useState(0);
+ return (
+
+ setVersion((v) => v + 1)} />
+
+
+
+ {t('retention.more', 'Schedules and every setting on this page')}
+
+
+
+
+
+
+ );
+}
diff --git a/src/features/retention/RetentionSheet.tsx b/src/features/retention/RetentionSheet.tsx
new file mode 100644
index 0000000..6153a8a
--- /dev/null
+++ b/src/features/retention/RetentionSheet.tsx
@@ -0,0 +1,227 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Coffey Labs
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+/**
+ * inbuxa: Storage › Retention, every "how long" in one place, as sentences
+ * (settings-reorg, second wave). Shown on each of the four retention pages,
+ * above that page's own settings (schedules and the like), which stay in
+ * the form below.
+ */
+
+import { useEffect, useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Loader2, Timer } from 'lucide-react';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { getAccountId, jmapGet, jmapSet } from '@/services/jmap/client';
+import { useAccountStore } from '@/stores/accountStore';
+import { useSchemaStore } from '@/stores/schemaStore';
+import type { JmapSetResponse } from '@/types/jmap';
+import { toast } from '@/hooks/use-toast';
+import { fetchLogSettings, updateLogSettings } from '@/features/hardening/logSettings';
+import { cn } from '@/lib/utils';
+import { fromParts, ITEMS, RETENTION_FIELDS, toParts, words, type Group, type Item, type Unit } from './items';
+
+const OBJECT = 'x:DataRetention';
+const DAY = 86_400_000;
+
+/** One value being edited: null is the setting's "none" (never, off). */
+type Draft = { n: string; unit: Unit } | null;
+
+function draftOf(ms: number | null | undefined): Draft {
+ if (ms === null || ms === undefined) return null;
+ const { n, unit } = toParts(ms);
+ return { n: String(n), unit };
+}
+
+function msOf(d: Draft): number | null {
+ if (d === null) return null;
+ const n = Number(d.n);
+ return Number.isFinite(n) && n > 0 ? fromParts(Math.round(n), d.unit) : NaN;
+}
+
+export function RetentionSheet({ onSaved }: { onSaved?: () => void }) {
+ const { t } = useTranslation();
+ const schema = useSchemaStore((s) => s.schema);
+ const canUpdate = useAccountStore((s) => s.hasObjectPermission('sysDataRetention', 'Update'));
+ const canLogs = useAccountStore((s) => s.hasPermission('sysTracerUpdate'));
+ const [saved, setSaved] = useState
| null>(null);
+ const [draft, setDraft] = useState>({});
+ const [busy, setBusy] = useState(false);
+
+ const defaults = schema?.fields[OBJECT]?.defaults ?? {};
+
+ useEffect(() => {
+ let live = true;
+ Promise.all([
+ jmapGet(OBJECT, getAccountId(OBJECT), ['singleton'], RETENTION_FIELDS),
+ fetchLogSettings().catch(() => undefined),
+ ])
+ .then(([[res], logs]) => {
+ const data = ((res?.[1] as { list?: Record[] })?.list ?? [])[0] ?? {};
+ const values: Record = {};
+ for (const f of RETENTION_FIELDS) values[f] = (data[f] as number | null | undefined) ?? null;
+ if (logs !== undefined) values.logs = logs === null ? null : logs * DAY;
+ if (!live) return;
+ setSaved(values);
+ setDraft(Object.fromEntries(Object.entries(values).map(([k, v]) => [k, draftOf(v)])));
+ })
+ .catch(() => live && setSaved(null));
+ return () => {
+ live = false;
+ };
+ }, []);
+
+ const changed = useMemo(() => {
+ if (!saved) return [];
+ // An unfinished number counts as a change, and the invalid check keeps it from saving.
+ return Object.keys(saved).filter((k) => msOf(draft[k] ?? null) !== saved[k]);
+ }, [saved, draft]);
+ const invalid = Object.values(draft).some((d) => d !== null && Number.isNaN(msOf(d)));
+
+ if (!saved) return null;
+
+ const groupTitle: Record = {
+ mailboxes: t('retention.mailboxes', 'In people’s mailboxes'),
+ recovery: t('retention.recovery', 'Getting deleted things back'),
+ records: t('retention.records', 'The server’s own records'),
+ };
+
+ const save = async () => {
+ setBusy(true);
+ try {
+ const patch: Record = {};
+ for (const k of changed) if (k !== 'logs') patch[k] = msOf(draft[k] ?? null);
+ if (Object.keys(patch).length > 0) {
+ const [res] = await jmapSet(OBJECT, getAccountId(OBJECT), { update: { singleton: patch } });
+ const body = res?.[1] as unknown as JmapSetResponse | undefined;
+ const bad = body?.notUpdated?.singleton;
+ if (bad) throw new Error(bad.description ?? bad.type);
+ }
+ if (changed.includes('logs')) {
+ const ms = msOf(draft.logs ?? null);
+ await updateLogSettings(ms === null ? null : Math.max(1, Math.round(ms / DAY)));
+ }
+ setSaved((s) => ({ ...s, ...Object.fromEntries(changed.map((k) => [k, msOf(draft[k] ?? null)])) }));
+ // Settings saves announce themselves ("Saved and applied"); log files alone don't.
+ if (Object.keys(patch).length === 0) toast({ title: t('retention.saved', 'Saved') });
+ onSaved?.();
+ } catch (e) {
+ toast({
+ variant: 'destructive',
+ title: t('retention.failed', 'Not saved'),
+ description: e instanceof Error ? e.message : String(e),
+ });
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const row = (item: Item) => {
+ if (!(item.field in saved)) return null;
+ const d = draft[item.field] ?? null;
+ const editable = item.field === 'logs' ? canLogs : canUpdate;
+ const def = defaults[item.field] as number | null | undefined;
+ const isChanged = changed.includes(item.field);
+ return (
+
+ {item.before}
+ {d === null ? (
+ {item.none}
+ ) : (
+ <>
+ setDraft((x) => ({ ...x, [item.field]: { n: e.target.value, unit: d.unit } }))}
+ className={cn('h-8 w-20', Number.isNaN(msOf(d)) && 'border-destructive')}
+ aria-label={item.before}
+ />
+ {item.field === 'logs' ? (
+ {t('retention.daysWord', 'days')}
+ ) : (
+
+ )}
+ >
+ )}
+ {item.after && {item.after}}
+ {editable && (
+
+ )}
+ {isChanged && (
+ {t('retention.unsaved', 'unsaved')}
+ )}
+ {(item.hint || def !== undefined) && (
+
+ {item.hint}
+ {item.hint && def !== undefined ? ' ' : ''}
+ {def !== undefined &&
+ t('retention.default', 'Default: {{value}}.', { value: def === null ? item.none : words(def) })}
+
+ )}
+
+ );
+ };
+
+ return (
+
+
+
+
{t('retention.title', 'How long things are kept')}
+ {changed.length > 0 && (
+
+ )}
+
+ {(['mailboxes', 'recovery', 'records'] as Group[]).map((g) => (
+
+ {groupTitle[g]}
+ {ITEMS.filter((i) => i.group === g).map(row)}
+
+ ))}
+
+ {t(
+ 'retention.holds',
+ 'Mail under a legal hold is kept whatever these say: deleting it archives it, and it doesn’t expire while held.',
+ )}
+
+
+ );
+}
diff --git a/src/features/retention/items.test.ts b/src/features/retention/items.test.ts
new file mode 100644
index 0000000..214b194
--- /dev/null
+++ b/src/features/retention/items.test.ts
@@ -0,0 +1,26 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Coffey Labs
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { describe, it, expect } from 'vitest';
+import { fromParts, ITEMS, RETENTION_FIELDS, toParts, words } from './items';
+
+describe('durations', () => {
+ it('show whole days as days and the rest as hours', () => {
+ expect(toParts(2_592_000_000)).toEqual({ n: 30, unit: 'days' });
+ expect(toParts(43_200_000)).toEqual({ n: 12, unit: 'hours' });
+ expect(fromParts(3, 'days')).toBe(259_200_000);
+ expect(words(86_400_000)).toBe('1 day');
+ expect(words(7_776_000_000)).toBe('90 days');
+ });
+});
+
+describe('items', () => {
+ it('cover every duration on x:DataRetention once, plus log files', () => {
+ expect(RETENTION_FIELDS).toHaveLength(9);
+ expect(new Set(ITEMS.map((i) => i.field)).size).toBe(ITEMS.length);
+ expect(ITEMS.some((i) => i.field === 'logs')).toBe(true);
+ });
+});
diff --git a/src/features/retention/items.ts b/src/features/retention/items.ts
new file mode 100644
index 0000000..ed65175
--- /dev/null
+++ b/src/features/retention/items.ts
@@ -0,0 +1,112 @@
+/*
+ * SPDX-FileCopyrightText: 2026 Coffey Labs
+ *
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+/**
+ * inbuxa: every "how long is this kept" setting as one sentence
+ * (settings-reorg, second wave). They live on x:DataRetention, spread over
+ * four pages, plus log files on inbuxa:LogSettings.
+ */
+
+export type Group = 'mailboxes' | 'recovery' | 'records';
+
+export interface Item {
+ /** A property of x:DataRetention, or 'logs' for rotated log files. */
+ field: string;
+ group: Group;
+ before: string;
+ after?: string;
+ /** What "no value" means for this setting. */
+ none: string;
+ hint?: string;
+}
+
+export const ITEMS: Item[] = [
+ {
+ field: 'expungeTrashAfter',
+ group: 'mailboxes',
+ before: 'Empty Trash and Junk after',
+ none: 'never',
+ },
+ {
+ field: 'expungeSchedulingInboxAfter',
+ group: 'mailboxes',
+ before: 'Clear handled calendar invitations after',
+ none: 'never',
+ },
+ {
+ field: 'expungeShareNotifyAfter',
+ group: 'mailboxes',
+ before: 'Forget “shared with you” notices after',
+ none: 'never',
+ },
+ {
+ field: 'expungeSubmissionsAfter',
+ group: 'mailboxes',
+ before: 'Forget the sending status of sent mail after',
+ none: 'never',
+ hint: 'What apps show as “delivered” or “failed”. The sent message itself stays.',
+ },
+ {
+ field: 'archiveDeletedItemsFor',
+ group: 'recovery',
+ before: 'Let people get deleted mail, contacts and events back for',
+ none: 'off: deleting is final',
+ },
+ {
+ field: 'archiveDeletedAccountsFor',
+ group: 'recovery',
+ before: 'Keep a deleted account restorable for',
+ none: 'off: deleting is final',
+ },
+ {
+ field: 'holdTracesFor',
+ group: 'records',
+ before: 'Keep delivery history for',
+ none: 'forever',
+ hint: 'What Emails › History and “why was this rejected?” read from.',
+ },
+ {
+ field: 'holdMetricsFor',
+ group: 'records',
+ before: 'Keep charts’ history for',
+ none: 'forever',
+ },
+ {
+ field: 'holdMtaReportsFor',
+ group: 'records',
+ before: 'Keep DMARC and TLS reports from other servers for',
+ none: 'off: not stored',
+ },
+ {
+ field: 'logs',
+ group: 'records',
+ before: 'Keep old log files for',
+ none: 'forever',
+ hint: 'Log files hold IP and email addresses.',
+ },
+];
+
+export const RETENTION_FIELDS = ITEMS.filter((i) => i.field !== 'logs').map((i) => i.field);
+
+export type Unit = 'hours' | 'days';
+const HOUR = 3_600_000;
+const DAY = 24 * HOUR;
+
+/** A stored duration as a number and unit someone would type. */
+export function toParts(ms: number): { n: number; unit: Unit } {
+ if (ms % DAY === 0) return { n: ms / DAY, unit: 'days' };
+ return { n: Math.max(1, Math.round(ms / HOUR)), unit: 'hours' };
+}
+
+export function fromParts(n: number, unit: Unit): number {
+ return n * (unit === 'days' ? DAY : HOUR);
+}
+
+/** "30 days", "1 day", "12 hours". */
+export function words(ms: number): string {
+ const { n, unit } = toParts(ms);
+ return `${n} ${n === 1 ? unit.slice(0, -1) : unit}`;
+}