+ {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]),
+ );
+}