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}`; +}