Reports in a few sentences, on every report page #44
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 { 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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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]),
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user