Retention on one page, as sentences #43

Merged
jcoffey-dev merged 1 commits from feature/retention-page into main 2026-09-28 22:00:49 +00:00
5 changed files with 406 additions and 0 deletions

No files matched your search

+5
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 { 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 <SenderChecksPage viewName={viewName} />;
}
// inbuxa: every retention page opens with all the "how long"s as sentences (settings-reorg).
if (resolved.objectName === 'x:DataRetention') {
return <RetentionPage viewName={viewName} />;
}
if (resolved.objectName === 'x:Security') {
return (
<div className="space-y-4">
+36
View File
@@ -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 (
<div className="space-y-4">
<RetentionSheet onSaved={() => setVersion((v) => v + 1)} />
<Collapsible className="mx-auto max-w-4xl">
<CollapsibleTrigger className="group flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground">
<ChevronDown className="h-4 w-4 transition-transform group-data-[state=closed]:-rotate-90" />
{t('retention.more', 'Schedules and every setting on this page')}
</CollapsibleTrigger>
<CollapsibleContent className="pt-4">
<DynamicForm key={version} viewName={viewName} objectId="singleton" />
</CollapsibleContent>
</Collapsible>
</div>
);
}
+227
View File
@@ -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<Record<string, number | null> | null>(null);
const [draft, setDraft] = useState<Record<string, Draft>>({});
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<string, unknown>[] })?.list ?? [])[0] ?? {};
const values: Record<string, number | null> = {};
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<Group, string> = {
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<string, number | null> = {};
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 (
<li key={item.field} className="flex flex-wrap items-center gap-x-2 gap-y-1 py-2 text-sm">
<span>{item.before}</span>
{d === null ? (
<span className="font-medium">{item.none}</span>
) : (
<>
<Input
type="number"
min={1}
value={d.n}
disabled={!editable}
onChange={(e) => 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' ? (
<span>{t('retention.daysWord', 'days')}</span>
) : (
<Select
value={d.unit}
disabled={!editable}
onValueChange={(u) => setDraft((x) => ({ ...x, [item.field]: { n: d.n, unit: u as Unit } }))}
>
<SelectTrigger className="h-8 w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="days">{t('retention.days', 'days')}</SelectItem>
<SelectItem value="hours">{t('retention.hours', 'hours')}</SelectItem>
</SelectContent>
</Select>
)}
</>
)}
{item.after && <span>{item.after}</span>}
{editable && (
<button
type="button"
className="text-xs text-primary hover:underline"
onClick={() =>
setDraft((x) => ({
...x,
[item.field]: d === null ? draftOf(typeof def === 'number' ? def : 30 * DAY) : null,
}))
}
>
{d === null
? t('retention.setTime', 'set a time')
: item.none.startsWith('off')
? t('retention.turnOff', 'turn off')
: item.none === 'forever'
? t('retention.keepForever', 'keep forever')
: t('retention.never', 'never')}
</button>
)}
{isChanged && (
<span className="rounded bg-primary/10 px-1.5 text-xs text-primary">{t('retention.unsaved', 'unsaved')}</span>
)}
{(item.hint || def !== undefined) && (
<span className="basis-full text-xs text-muted-foreground">
{item.hint}
{item.hint && def !== undefined ? ' ' : ''}
{def !== undefined &&
t('retention.default', 'Default: {{value}}.', { value: def === null ? item.none : words(def) })}
</span>
)}
</li>
);
};
return (
<div className="mx-auto max-w-4xl space-y-4 rounded-xl border bg-card p-5">
<div className="flex items-center gap-3">
<Timer className="h-5 w-5 text-primary" />
<p className="flex-1 font-medium">{t('retention.title', 'How long things are kept')}</p>
{changed.length > 0 && (
<Button type="button" size="sm" onClick={() => void save()} disabled={busy || invalid}>
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
{t('retention.save', 'Save {{count}} changes', { count: changed.length })}
</Button>
)}
</div>
{(['mailboxes', 'recovery', 'records'] as Group[]).map((g) => (
<section key={g}>
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">{groupTitle[g]}</h3>
<ul className="divide-y">{ITEMS.filter((i) => i.group === g).map(row)}</ul>
</section>
))}
<p className="text-xs text-muted-foreground">
{t(
'retention.holds',
'Mail under a legal hold is kept whatever these say: deleting it archives it, and it doesn’t expire while held.',
)}
</p>
</div>
);
}
+26
View File
@@ -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);
});
});
+112
View File
@@ -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}`;
}