diff --git a/src/components/layout/MainContent.tsx b/src/components/layout/MainContent.tsx index 4322b81..de3e74b 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 { 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 ; } + // 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 ; + } if (resolved.objectName === 'x:Security') { return (
diff --git a/src/features/reports/ReportsPage.tsx b/src/features/reports/ReportsPage.tsx new file mode 100644 index 0000000..29a17dd --- /dev/null +++ b/src/features/reports/ReportsPage.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 { 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 ( +
+ setVersion((v) => v + 1)} /> + + + + {t('reports.more', 'Names, subjects and every setting on this page')} + + + + + +
+ ); +} diff --git a/src/features/reports/ReportsSheet.tsx b/src/features/reports/ReportsSheet.tsx new file mode 100644 index 0000000..a5d77e4 --- /dev/null +++ b/src/features/reports/ReportsSheet.tsx @@ -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 { + const get = (obj: string, ids: string[] | null, properties: string[], id: string) => + [`${obj}/get`, { accountId: getAccountId(obj), ids, properties }, id] as [string, Record, 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[] } | undefined)?.list ?? [])[0] ?? {}; + const all = (id: string) => + (res.find(([, , i]) => i === id)?.[1] as { list?: Record[] } | 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) ?? {}).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(null); + const [draft, setDraft] = useState(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: K, v: State[K]) => setDraft((d) => (d ? { ...d, [k]: v } : d)); + const freqLabel: Record = { + 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][] = []; + const add = (obj: string, patch: Record) => 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 = {}; + 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 ? ( + {t('reports.byRule', 'by a rule on its page')} + ) : ( + + ); + + return ( +
+
+ +

{t('reports.title', 'Reports, in short')}

+ {changed.length > 0 && ( + + )} +
+ +
+

+ {t('reports.outgoing', 'Reports this server sends')} +

+
    +
  • + + {t('reports.dmarcSentence', 'Tell other domains how mail claiming to be from them fared here (DMARC)')} + + {freqSelect('dmarc')} +
  • +
  • + {t('reports.tlsSentence', 'Tell them whether their mail reached us encrypted (TLS)')} + {freqSelect('tls')} +
  • +
  • + + {t( + 'reports.failureSentence', + 'Report each message that fails their checks (DMARC, DKIM, SPF), at most once a day per address', + )} + + {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.', + )} + + + set('failures', v ? 'on' : 'off')} + /> +
  • +
  • + {t('reports.fromSentence', 'Send them, and bounce messages, from addresses at')} + + + {t('reports.fromExample', 'For example noreply-dmarc@{{domain}} and MAILER-DAEMON@{{domain}}.', { + domain: fromDomain || 'example.org', + })} + +
  • +
+
+ +
+

+ {t('reports.incoming', 'Reports other servers send you')} +

+
    +
  • + {t('reports.inboundSentence', 'Read reports sent to')} + set('inbound', e.target.value)} + className="h-8 max-w-xs" + aria-label={t('reports.inboundSentence', 'Read reports sent to')} + /> + {t('reports.inboundAfter', 'and show them under Management › Reports')} + + {t( + 'reports.inboundHint', + 'Separate addresses with commas. * matches any domain: postmaster@* is every domain’s postmaster.', + )} + +
  • +
  • + {t('reports.forwardSentence', 'Then deliver them to that mailbox as well')} + set('forward', v)} /> +
  • +
+
+
+ ); +} diff --git a/src/features/reports/values.test.ts b/src/features/reports/values.test.ts new file mode 100644 index 0000000..acabb60 --- /dev/null +++ b/src/features/reports/values.test.ts @@ -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@*, DMARC@example.org\nabuse@x.org ')).toEqual({ + 'postmaster@*': true, + 'dmarc@example.org': true, + 'abuse@x.org': true, + }); + }); +}); diff --git a/src/features/reports/values.ts b/src/features/reports/values.ts new file mode 100644 index 0000000..7b5707e --- /dev/null +++ b/src/features/reports/values.ts @@ -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; 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; + 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@*, dmarc@example.org" ⇄ the set the server stores. */ +export function addressSet(text: string): Record { + return Object.fromEntries( + text + .split(/[\s,]+/) + .map((a) => a.trim().toLowerCase()) + .filter(Boolean) + .map((a) => [a, true]), + ); +}