From 7b65b8eb34f4cbd94315768044b32e13dbe018fa Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 28 Sep 2026 12:58:10 -0700 Subject: [PATCH] Retention on one page, as sentences Every "how long is this kept" setting was spread over four pages of fields named after the config, plus log files on the Hardening page. Each retention page now opens with all of them as sentences: - in people's mailboxes: empty Trash and Junk after, clear handled calendar invitations after, forget share notices after, forget the sending status of sent mail after; - getting deleted things back: deleted items and deleted accounts; - the server's own records: delivery history, charts' history, DMARC and TLS reports from other servers, and old log files. Each has a number and unit (or its own never / off / forever), its default beneath, and an unsaved mark until the changes are saved together. It says that mail under a legal hold is kept whatever these say. The page's own settings (schedules and the like) are folded underneath and reload after a save. --- src/components/layout/MainContent.tsx | 5 + src/features/retention/RetentionPage.tsx | 36 ++++ src/features/retention/RetentionSheet.tsx | 227 ++++++++++++++++++++++ src/features/retention/items.test.ts | 26 +++ src/features/retention/items.ts | 112 +++++++++++ 5 files changed, 406 insertions(+) create mode 100644 src/features/retention/RetentionPage.tsx create mode 100644 src/features/retention/RetentionSheet.tsx create mode 100644 src/features/retention/items.test.ts create mode 100644 src/features/retention/items.ts 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}`; +} -- 2.54.0