Reports in a few sentences, on every report page #44

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

No files matched your search

+14
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 { ReportsPage } from '@/features/reports/ReportsPage';
import { RetentionPage } from '@/features/retention/RetentionPage';
import { LimitsSummary } from '@/features/limits/LimitsSummary';
import { SenderChecksPage } from '@/features/senderChecks/SenderChecksPage';
@@ -254,6 +255,19 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti
if (resolved.objectName === 'x:DataRetention') {
return <RetentionPage viewName={viewName} />;
}
// inbuxa: every report page opens with what goes out and comes in (settings-reorg).
if (
[
'x:ReportSettings',
'x:DmarcReportSettings',
'x:TlsReportSettings',
'x:DkimReportSettings',
'x:SpfReportSettings',
'x:DsnReportSettings',
].includes(resolved.objectName)
) {
return <ReportsPage 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 { ReportsSheet } from './ReportsSheet';
/**
* A report page: what goes out and what comes in, in a few sentences, then
* this page's own settings (names, subjects, signing) folded underneath and
* reloaded after a save.
*/
export function ReportsPage({ viewName }: { viewName: string }) {
const { t } = useTranslation();
const [version, setVersion] = useState(0);
return (
<div className="space-y-4">
<ReportsSheet 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('reports.more', 'Names, subjects and every setting on this page')}
</CollapsibleTrigger>
<CollapsibleContent className="pt-4">
<DynamicForm key={version} viewName={viewName} objectId="singleton" />
</CollapsibleContent>
</Collapsible>
</div>
);
}
+310
View File
@@ -0,0 +1,310 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* inbuxa: Mail flow › Reports, what goes out and what comes in, as a few
* sentences on every report page (settings-reorg, second wave). The six
* pages keep their own settings (names, subjects, signing) folded below.
*/
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FileText, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Switch } from '@/components/ui/switch';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { getAccountId, jmapRequest } from '@/services/jmap/client';
import { useAccountStore } from '@/stores/accountStore';
import type { JmapSetResponse } from '@/types/jmap';
import { toast } from '@/hooks/use-toast';
import {
addressSet,
constant,
FAILURE_OFF,
FAILURE_ON,
failuresTogether,
frequencyOf,
FREQUENCIES,
type Frequency,
} from './values';
const FAILURE_OBJECTS: [string, string][] = [
['x:DmarcReportSettings', 'failureSendFrequency'],
['x:DkimReportSettings', 'sendFrequency'],
['x:SpfReportSettings', 'sendFrequency'],
];
interface State {
dmarc: Frequency | null;
tls: Frequency | null;
failures: 'on' | 'off' | 'mixed';
reportDomain: string | null;
inbound: string;
forward: boolean;
}
interface Loaded {
state: State;
domains: string[];
defaultDomain: string;
}
async function load(): Promise<Loaded> {
const get = (obj: string, ids: string[] | null, properties: string[], id: string) =>
[`${obj}/get`, { accountId: getAccountId(obj), ids, properties }, id] as [string, Record<string, unknown>, string];
const res = await jmapRequest([
get('x:DmarcReportSettings', ['singleton'], ['aggregateSendFrequency', 'failureSendFrequency'], 'dmarc'),
get('x:TlsReportSettings', ['singleton'], ['sendFrequency'], 'tls'),
get('x:DkimReportSettings', ['singleton'], ['sendFrequency'], 'dkim'),
get('x:SpfReportSettings', ['singleton'], ['sendFrequency'], 'spf'),
get(
'x:ReportSettings',
['singleton'],
['inboundReportAddresses', 'inboundReportForwarding', 'outboundReportDomain'],
'rs',
),
get('x:Domain', null, ['name'], 'dom'),
get('x:SystemSettings', ['singleton'], ['defaultDomainId'], 'sys'),
]);
const one = (id: string) =>
((res.find(([, , i]) => i === id)?.[1] as { list?: Record<string, unknown>[] } | undefined)?.list ?? [])[0] ?? {};
const all = (id: string) =>
(res.find(([, , i]) => i === id)?.[1] as { list?: Record<string, unknown>[] } | undefined)?.list ?? [];
const dmarc = one('dmarc');
const rs = one('rs');
const domains = all('dom');
const defaultId = one('sys').defaultDomainId as string | undefined;
return {
state: {
dmarc: frequencyOf(dmarc.aggregateSendFrequency),
tls: frequencyOf(one('tls').sendFrequency),
failures: failuresTogether([dmarc.failureSendFrequency, one('dkim').sendFrequency, one('spf').sendFrequency]),
reportDomain: (rs.outboundReportDomain as string | null) ?? null,
inbound: Object.keys((rs.inboundReportAddresses as Record<string, boolean>) ?? {}).join(', '),
forward: rs.inboundReportForwarding !== false,
},
domains: domains.map((d) => String(d.name)).sort(),
defaultDomain: String(domains.find((d) => d.id === defaultId)?.name ?? ''),
};
}
export function ReportsSheet({ onSaved }: { onSaved?: () => void }) {
const { t } = useTranslation();
const canUpdate = useAccountStore((s) => s.hasObjectPermission('sysReportSettings', 'Update'));
const [loaded, setLoaded] = useState<Loaded | null>(null);
const [draft, setDraft] = useState<State | null>(null);
const [busy, setBusy] = useState(false);
useEffect(() => {
let live = true;
load()
.then((l) => {
if (!live) return;
setLoaded(l);
setDraft(l.state);
})
.catch(() => undefined);
return () => {
live = false;
};
}, []);
const changed = useMemo(() => {
if (!loaded || !draft) return [] as (keyof State)[];
return (Object.keys(draft) as (keyof State)[]).filter((k) => draft[k] !== loaded.state[k]);
}, [loaded, draft]);
if (!loaded || !draft) return null;
const set = <K extends keyof State>(k: K, v: State[K]) => setDraft((d) => (d ? { ...d, [k]: v } : d));
const freqLabel: Record<Frequency, string> = {
hourly: t('reports.hourly', 'every hour'),
daily: t('reports.daily', 'once a day'),
weekly: t('reports.weekly', 'once a week'),
disable: t('reports.never', 'never'),
};
const fromDomain = draft.reportDomain ?? loaded.defaultDomain;
const save = async () => {
setBusy(true);
const calls: [string, Record<string, unknown>][] = [];
const add = (obj: string, patch: Record<string, unknown>) => calls.push([obj, patch]);
if (changed.includes('dmarc') && draft.dmarc)
add('x:DmarcReportSettings', { aggregateSendFrequency: constant(draft.dmarc) });
if (changed.includes('tls') && draft.tls) add('x:TlsReportSettings', { sendFrequency: constant(draft.tls) });
if (changed.includes('failures') && draft.failures !== 'mixed') {
const value = constant(draft.failures === 'on' ? FAILURE_ON : FAILURE_OFF);
for (const [obj, prop] of FAILURE_OBJECTS) {
const existing = calls.find(([o]) => o === obj);
if (existing) existing[1][prop] = value;
else add(obj, { [prop]: value });
}
}
const rs: Record<string, unknown> = {};
if (changed.includes('reportDomain')) rs.outboundReportDomain = draft.reportDomain;
if (changed.includes('inbound')) rs.inboundReportAddresses = addressSet(draft.inbound);
if (changed.includes('forward')) rs.inboundReportForwarding = draft.forward;
if (Object.keys(rs).length > 0) add('x:ReportSettings', rs);
try {
const responses = await jmapRequest(
calls.map(([obj, patch], i) => [
`${obj}/set`,
{ accountId: getAccountId(obj), update: { singleton: patch } },
String(i),
]),
);
const failed = responses
.map(([, body]) => (body as unknown as JmapSetResponse).notUpdated?.singleton)
.filter(Boolean)
.map((e) => e!.description ?? e!.type);
if (failed.length > 0) throw new Error(failed.join('; '));
const fresh = await load();
setLoaded(fresh);
setDraft(fresh.state);
onSaved?.();
} catch (e) {
toast({
variant: 'destructive',
title: t('reports.failed', 'Not saved'),
description: e instanceof Error ? e.message : String(e),
});
} finally {
setBusy(false);
}
};
const freqSelect = (k: 'dmarc' | 'tls') =>
draft[k] === null ? (
<span className="font-medium">{t('reports.byRule', 'by a rule on its page')}</span>
) : (
<Select value={draft[k]!} disabled={!canUpdate} onValueChange={(v) => set(k, v as Frequency)}>
<SelectTrigger className="h-8 w-36">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FREQUENCIES.map((f) => (
<SelectItem key={f} value={f}>
{freqLabel[f]}
</SelectItem>
))}
</SelectContent>
</Select>
);
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">
<FileText className="h-5 w-5 text-primary" />
<p className="flex-1 font-medium">{t('reports.title', 'Reports, in short')}</p>
{changed.length > 0 && (
<Button type="button" size="sm" onClick={() => void save()} disabled={busy}>
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
{t('reports.save', 'Save changes')}
</Button>
)}
</div>
<section className="space-y-1">
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{t('reports.outgoing', 'Reports this server sends')}
</h3>
<ul className="divide-y text-sm">
<li className="flex flex-wrap items-center gap-2 py-2">
<span>
{t('reports.dmarcSentence', 'Tell other domains how mail claiming to be from them fared here (DMARC)')}
</span>
{freqSelect('dmarc')}
</li>
<li className="flex flex-wrap items-center gap-2 py-2">
<span>{t('reports.tlsSentence', 'Tell them whether their mail reached us encrypted (TLS)')}</span>
{freqSelect('tls')}
</li>
<li className="flex flex-wrap items-center justify-between gap-2 py-2">
<span className="min-w-0 flex-1">
{t(
'reports.failureSentence',
'Report each message that fails their checks (DMARC, DKIM, SPF), at most once a day per address',
)}
<span className="block text-xs text-muted-foreground">
{draft.failures === 'mixed'
? t(
'reports.mixed',
'Set differently on the DMARC, DKIM and SPF pages. Switching here sets all three.',
)
: t(
'reports.privacy',
'These include the failing message’s headers, which carry people’s addresses. Most large providers don’t send them.',
)}
</span>
</span>
<Switch
checked={draft.failures === 'on'}
disabled={!canUpdate}
onCheckedChange={(v) => set('failures', v ? 'on' : 'off')}
/>
</li>
<li className="flex flex-wrap items-center gap-2 py-2">
<span>{t('reports.fromSentence', 'Send them, and bounce messages, from addresses at')}</span>
<Select
value={draft.reportDomain ?? '__default__'}
disabled={!canUpdate}
onValueChange={(v) => set('reportDomain', v === '__default__' ? null : v)}
>
<SelectTrigger className="h-8 w-auto min-w-56 max-w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="__default__">
{t('reports.defaultDomain', 'the default domain ({{name}})', { name: loaded.defaultDomain || '—' })}
</SelectItem>
{loaded.domains.map((d) => (
<SelectItem key={d} value={d}>
{d}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="basis-full text-xs text-muted-foreground">
{t('reports.fromExample', 'For example noreply-dmarc@{{domain}} and MAILER-DAEMON@{{domain}}.', {
domain: fromDomain || 'example.org',
})}
</span>
</li>
</ul>
</section>
<section className="space-y-1">
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{t('reports.incoming', 'Reports other servers send you')}
</h3>
<ul className="divide-y text-sm">
<li className="flex flex-wrap items-center gap-2 py-2">
<span>{t('reports.inboundSentence', 'Read reports sent to')}</span>
<Input
value={draft.inbound}
disabled={!canUpdate}
onChange={(e) => set('inbound', e.target.value)}
className="h-8 max-w-xs"
aria-label={t('reports.inboundSentence', 'Read reports sent to')}
/>
<span>{t('reports.inboundAfter', 'and show them under Management › Reports')}</span>
<span className="basis-full text-xs text-muted-foreground">
{t(
'reports.inboundHint',
'Separate addresses with commas. * matches any domain: postmaster@* is every domain’s postmaster.',
)}
</span>
</li>
<li className="flex items-center justify-between gap-2 py-2">
<span>{t('reports.forwardSentence', 'Then deliver them to that mailbox as well')}</span>
<Switch checked={draft.forward} disabled={!canUpdate} onCheckedChange={(v) => set('forward', v)} />
</li>
</ul>
</section>
</div>
);
}
+40
View File
@@ -0,0 +1,40 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, it, expect } from 'vitest';
import { addressSet, failureOf, failuresTogether, frequencyOf, plain } from './values';
describe('report values', () => {
it('read plain values and leave conditions alone', () => {
expect(plain({ match: {}, else: 'daily' })).toBe('daily');
expect(plain({ match: { '0': { if: 'x', then: 'y' } }, else: 'daily' })).toBeNull();
expect(frequencyOf({ match: {}, else: 'weekly' })).toBe('weekly');
expect(frequencyOf({ match: {}, else: "'weekly'" })).toBeNull();
});
it('tell a rate from no rate', () => {
expect(failureOf({ match: {}, else: '[1, 1d]' })).toBe('on');
expect(failureOf({ match: {}, else: '[5, 1h]' })).toBe('on');
expect(failureOf({ match: {}, else: 'false' })).toBe('off');
expect(failureOf({ match: { '0': { if: 'a', then: 'b' } }, else: 'false' })).toBeNull();
});
it('read the three failure switches together', () => {
const on = { match: {}, else: '[1, 1d]' };
const off = { match: {}, else: 'false' };
expect(failuresTogether([on, on, on])).toBe('on');
expect(failuresTogether([off, off, off])).toBe('off');
expect(failuresTogether([on, off, on])).toBe('mixed');
});
it('turn a typed list of addresses into a set', () => {
expect(addressSet(' postmaster@*, [email protected]\[email protected] ')).toEqual({
'postmaster@*': true,
'[email protected]': true,
'[email protected]': true,
});
});
});
+69
View File
@@ -0,0 +1,69 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* inbuxa: the report settings that decide whether reports go out, read
* from and written to their expressions (settings-reorg, second wave).
*
* - Summaries (DMARC and TLS aggregate reports) take hourly, daily, weekly
* or disable.
* - Failure reports (DMARC, DKIM, SPF) take a rate such as [1, 1d]: at most
* one a day to each address. Anything that isn't a rate, such as false,
* sends none (common/src/expr: a value that won't convert to a Rate makes
* eval_if answer None).
*
* Only plain values are read. An expression with conditions is left to its
* own page.
*/
export type Expr = { match: Record<string, { if: string; then: string }>; else: string };
export type Frequency = 'hourly' | 'daily' | 'weekly' | 'disable';
export const FREQUENCIES: Frequency[] = ['hourly', 'daily', 'weekly', 'disable'];
export const FAILURE_ON = '[1, 1d]';
export const FAILURE_OFF = 'false';
/** The plain value of an expression, or null when it has conditions. */
export function plain(e: unknown): string | null {
const x = (e ?? {}) as Partial<Expr>;
if (Object.keys(x.match ?? {}).length > 0) return null;
return (x.else ?? '').trim();
}
export function frequencyOf(e: unknown): Frequency | null {
const v = plain(e);
return v !== null && (FREQUENCIES as string[]).includes(v) ? (v as Frequency) : null;
}
/** 'on' for a rate, 'off' for anything that isn't one, null for conditions. */
export function failureOf(e: unknown): 'on' | 'off' | null {
const v = plain(e);
if (v === null) return null;
return /^\[\s*\d+\s*,\s*[^\]]+\]$/.test(v) ? 'on' : 'off';
}
export function constant(value: string): Expr {
return { match: {}, else: value };
}
/** The three failure-report switches read together: one answer if they agree. */
export function failuresTogether(values: unknown[]): 'on' | 'off' | 'mixed' {
const states = values.map(failureOf);
if (states.every((s) => s === 'on')) return 'on';
if (states.every((s) => s === 'off')) return 'off';
return 'mixed';
}
/** "postmaster@*, [email protected]" ⇄ the set the server stores. */
export function addressSet(text: string): Record<string, boolean> {
return Object.fromEntries(
text
.split(/[\s,]+/)
.map((a) => a.trim().toLowerCase())
.filter(Boolean)
.map((a) => [a, true]),
);
}