Retention on one page, as sentences #43
No files matched your search
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
Reference in new issue
Block a user