Reports › Scheduled: scheduled reports and the weekly digest #93

Merged
jcoffey-dev merged 1 commits from feature/scheduled-reports into main 2026-10-06 22:41:06 +00:00
14 changed files with 1419 additions and 1 deletions

No files matched your search

+8
View File
@@ -137,6 +137,11 @@ const SharedMailboxesPage = lazyFeature(
() => import('@/features/lock/SharedMailboxesPage'),
(m) => m.SharedMailboxesPage,
);
// inbuxa: Reports › Scheduled (scheduled-reports spec).
const ScheduledReportsPage = lazyFeature(
() => import('@/features/scheduledReports/ScheduledReportsPage'),
(m) => m.ScheduledReportsPage,
);
// inbuxa: Domains › Deliverability (deliverability spec, DL-17).
const DeliverabilityPage = lazyFeature(
() => import('@/features/deliverability/DeliverabilityPage'),
@@ -256,6 +261,9 @@ function renderView(schema: Schema | null, viewName?: string, id?: string, secti
if (componentName === 'Deliverability') {
return <DeliverabilityPage />;
}
if (componentName === 'ScheduledReports') {
return <ScheduledReportsPage />;
}
if (componentName === 'LegalHolds') {
return <LegalHoldsPage />;
}
+51
View File
@@ -0,0 +1,51 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, expect, it } from 'vitest';
import type { TFunction } from 'i18next';
import { buildAttention } from './attention';
const t = ((key: string, opts?: unknown) => {
const o = (typeof opts === 'object' && opts ? opts : {}) as Record<string, unknown>;
const text = (o.count === 1 ? o.defaultValue_one : o.defaultValue_other) ?? (typeof opts === 'string' ? opts : key);
return String(text);
}) as unknown as TFunction;
const quiet = {
t,
may: () => true,
facts: null,
health: null,
critical: null,
samples: [],
liveStatus: '',
nodesHref: null,
slowHref: null,
linkOf: () => null,
};
describe('buildAttention: scheduled reports (RP-17)', () => {
it('lists reports that keep failing, linking to their page', () => {
const items = buildAttention({
...quiet,
failingReports: 2,
failingReportsHref: '/Management/CustomComponent/ScheduledReports',
});
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({
id: 'scheduledReports',
severity: 'warn',
count: 2,
title: 'scheduled reports keep failing',
href: '/Management/CustomComponent/ScheduledReports',
});
});
it('says nothing when none do', () => {
expect(buildAttention({ ...quiet, failingReports: 0 })).toEqual([]);
expect(buildAttention({ ...quiet, failingReports: null })).toEqual([]);
});
});
+19
View File
@@ -42,6 +42,9 @@ export interface AttentionInput {
/** inbuxa: blocklist and reverse-DNS failures on Domains › Deliverability (DL-18). */
deliverability?: number | null;
deliverabilityHref?: string | null;
/** inbuxa: scheduled reports whose last two runs failed (RP-17). */
failingReports?: number | null;
failingReportsHref?: string | null;
samples: Metric[];
liveStatus: string;
nodesHref: string | null;
@@ -58,6 +61,8 @@ export function buildAttention({
critical,
deliverability,
deliverabilityHref,
failingReports,
failingReportsHref,
samples,
liveStatus,
nodesHref,
@@ -95,6 +100,20 @@ export function buildAttention({
detail: t('cc.a.deliverabilityDetail', 'Other mail servers may refuse mail from this one.'),
href: deliverabilityHref ?? null,
});
if (failingReports)
attention.push({
id: 'scheduledReports',
severity: 'warn',
icon: MailWarning,
count: failingReports,
title: t('cc.a.scheduledReports', {
count: failingReports,
defaultValue_one: 'scheduled report keeps failing',
defaultValue_other: 'scheduled reports keep failing',
}),
detail: t('cc.a.scheduledReportsDetail', 'Their last two runs failed; the report page says why.'),
href: failingReportsHref ?? null,
});
if (health && health.unhealthy)
attention.push({
id: 'nodes',
@@ -13,6 +13,7 @@ import { LEGACY_PROTOCOLS_VIEW } from '@/features/hardening/LegacyProtocolsBanne
import { useLegacyProtocolsOff } from '@/features/hardening/useLegacyProtocolsOff';
import { useCriticalCount } from '@/features/security/useCriticalCount';
import { useDeliverabilityAlarms } from '@/features/deliverability/useDeliverabilityAlarms';
import { useReportFailures } from '@/features/scheduledReports/useReportFailures';
import { useSchemaStore } from '@/stores/schemaStore';
import type { Dashboard } from '../types/schema';
import type { Metric } from '../types/metrics';
@@ -64,6 +65,8 @@ export function DashboardShell({
const critical = useCriticalCount();
// inbuxa: DL-18
const deliverability = useDeliverabilityAlarms(tick);
const failingReports = useReportFailures(tick);
const reportsSection = useSchemaStore((s) => s.viewToSection['CustomComponent/ScheduledReports']);
const deliverabilitySection = useSchemaStore((s) => s.viewToSection['CustomComponent/Deliverability']);
const legacyOff = useLegacyProtocolsOff();
@@ -106,6 +109,8 @@ export function DashboardShell({
critical,
deliverability,
deliverabilityHref: deliverabilitySection ? `/${deliverabilitySection}/CustomComponent/Deliverability` : null,
failingReports,
failingReportsHref: reportsSection ? `/${reportsSection}/CustomComponent/ScheduledReports` : null,
samples,
liveStatus,
nodesHref: health ? `/${section}/Dashboard/cluster` : null,
@@ -0,0 +1,135 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* inbuxa: Download (scheduled-reports spec, RP-19): the report for a period
* as a ZIP of a summary and CSVs, mailing nobody. Up to 90 days back, as far
* as the server keeps its numbers.
*/
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Download, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { downloadReport, type ScheduledReport } from './api';
type Period = 'last' | '7' | '30' | 'custom';
const DAY = 86_400_000;
const isoDay = (d: Date) => d.toISOString().slice(0, 10);
export function DownloadDialog({ report, onClose }: { report: ScheduledReport; onClose: () => void }) {
const { t } = useTranslation();
const [period, setPeriod] = useState<Period>('last');
const today = new Date();
const [from, setFrom] = useState(isoDay(new Date(today.getTime() - 7 * DAY)));
const [to, setTo] = useState(isoDay(today));
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const earliest = isoDay(new Date(today.getTime() - 89 * DAY));
const go = async () => {
setBusy(true);
setError(null);
try {
const now = new Date();
if (period === 'last') await downloadReport(report);
else if (period === 'custom') {
// Whole UTC days; a period ending today ends now
const end = new Date(`${to}T00:00:00Z`).getTime() + DAY;
await downloadReport(report, new Date(`${from}T00:00:00Z`), new Date(Math.min(end, now.getTime())));
} else await downloadReport(report, new Date(now.getTime() - Number(period) * DAY), now);
onClose();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setBusy(false);
}
};
const options: { value: Period; label: string }[] = [
{ value: 'last', label: t('schedRep.dlLast', 'Its last full period') },
{ value: '7', label: t('schedRep.dl7', 'The last 7 days') },
{ value: '30', label: t('schedRep.dl30', 'The last 30 days') },
{ value: 'custom', label: t('schedRep.dlCustom', 'Pick dates') },
];
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t('schedRep.dlTitle', 'Download {{name}}', { name: report.name })}</DialogTitle>
<DialogDescription>
{t(
'schedRep.dlHint',
'A ZIP with the summary and a CSV for each section that has rows. Nobody is mailed. Up to 90 days back.',
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-2" role="radiogroup">
{options.map((o) => (
<label key={o.value} className="flex items-center gap-2 text-sm">
<input
type="radio"
name="period"
value={o.value}
checked={period === o.value}
onChange={() => setPeriod(o.value)}
/>
{o.label}
</label>
))}
{period === 'custom' && (
<div className="grid grid-cols-2 gap-3 pl-6">
<div className="space-y-1">
<Label htmlFor="dl-from">{t('schedRep.dlFrom', 'From')}</Label>
<Input
id="dl-from"
type="date"
min={earliest}
max={to}
value={from}
onChange={(e) => setFrom(e.target.value)}
/>
</div>
<div className="space-y-1">
<Label htmlFor="dl-to">{t('schedRep.dlTo', 'To')}</Label>
<Input
id="dl-to"
type="date"
min={from}
max={isoDay(today)}
value={to}
onChange={(e) => setTo(e.target.value)}
/>
</div>
</div>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
{t('common.cancel', 'Cancel')}
</Button>
<Button type="button" disabled={busy} onClick={() => void go()}>
{busy ? <Loader2 className="mr-1.5 h-4 w-4 animate-spin" /> : <Download className="mr-1.5 h-4 w-4" />}
{t('schedRep.dlGo', 'Download ZIP')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,288 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* inbuxa: making or changing a scheduled report (scheduled-reports spec):
* name, sections with what each holds, when it goes in which time zone, who
* gets it (accounts on this server; the digest's are the administrators),
* and CSV files.
*/
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Combobox } from '@/components/ui/combobox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { useAuthStore } from '@/stores/authStore';
import { createReport, updateReport, type Frequency, type ReportInput, type ScheduledReport } from './api';
import { browserTimeZone, newReport, parseRecipients, SECTIONS, SERVER_WIDE, timeZones } from './describe';
import { useSectionLabels } from './sections';
export function ReportEditor({
report,
tenant,
onClose,
}: {
report: ScheduledReport | null;
/** A tenant administrator's report leaves out the server-wide sections. */
tenant: boolean;
onClose: (saved: boolean) => void;
}) {
const { t, i18n } = useTranslation();
const labels = useSectionLabels();
const me = useAuthStore((s) => s.username);
const [input, setInput] = useState<ReportInput>(() =>
report
? {
name: report.name,
sections: report.sections,
schedule: report.schedule,
recipients: report.recipients,
attachCsv: report.attachCsv,
}
: newReport(me && me.includes('@') ? me : null, browserTimeZone()),
);
const [recipientText, setRecipientText] = useState(input.recipients.join('\n'));
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const builtIn = report?.builtIn ?? false;
const s = input.schedule;
const zones = useMemo(
() => timeZones(s.timeZone).map((z) => ({ value: z, label: z.replace(/_/g, ' ') })),
[s.timeZone],
);
const weekdays = useMemo(
() =>
[1, 2, 3, 4, 5, 6, 7].map((d) => ({
value: String(d),
label: new Date(Date.UTC(2026, 9, 4 + d)).toLocaleDateString(i18n.language, {
weekday: 'long',
timeZone: 'UTC',
}),
})),
[i18n.language],
);
const setSchedule = (patch: Partial<ReportInput['schedule']>) =>
setInput((i) => ({ ...i, schedule: { ...i.schedule, ...patch } }));
const toggleSection = (name: (typeof SECTIONS)[number], on: boolean) =>
setInput((i) => ({
...i,
sections: SECTIONS.filter((x) => (x === name ? on : i.sections.includes(x))),
}));
const save = async () => {
setSaving(true);
setError(null);
const body: ReportInput = {
...input,
name: input.name.trim(),
sections: tenant ? input.sections.filter((x) => !SERVER_WIDE.has(x)) : input.sections,
recipients: builtIn ? [] : parseRecipients(recipientText),
};
try {
if (report) {
const { recipients, ...rest } = body;
await updateReport(report.id, builtIn ? rest : { ...rest, recipients });
} else {
await createReport(body);
}
onClose(true);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setSaving(false);
}
};
return (
<Dialog open onOpenChange={(open) => !open && onClose(false)}>
<DialogContent className="max-h-[90vh] max-w-2xl overflow-y-auto">
<DialogHeader>
<DialogTitle>
{report
? t('schedRep.editTitle', 'Edit {{name}}', { name: report.name })
: t('schedRep.newTitle', 'New report')}
</DialogTitle>
<DialogDescription>
{t(
'schedRep.editHint',
'Built when it’s due, from what the server already keeps. Empty sections are left out.',
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-5">
<div className="space-y-1">
<Label htmlFor="rep-name">{t('schedRep.name', 'Name')}</Label>
<Input
id="rep-name"
value={input.name}
maxLength={100}
placeholder={t('schedRep.namePlaceholder', 'e.g. Monday morning summary')}
onChange={(e) => setInput((i) => ({ ...i, name: e.target.value }))}
/>
</div>
<fieldset className="space-y-2">
<legend className="text-sm font-medium">{t('schedRep.sections', 'What it covers')}</legend>
{SECTIONS.map((name) => {
const off = tenant && SERVER_WIDE.has(name);
return (
<label key={name} className={`flex gap-3 rounded-md border p-2 ${off ? 'opacity-60' : ''}`}>
<Checkbox
className="mt-0.5"
checked={!off && input.sections.includes(name)}
disabled={off}
onCheckedChange={(v) => toggleSection(name, v === true)}
/>
<span className="text-sm">
<span className="font-medium">{labels[name].label}</span>
<span className="block text-muted-foreground">
{off ? t('schedRep.serverWide', 'Server-wide, so not in a tenant’s report.') : labels[name].hint}
</span>
</span>
</label>
);
})}
</fieldset>
<fieldset className="space-y-2">
<legend className="text-sm font-medium">{t('schedRep.when', 'When')}</legend>
<div className="grid gap-3 sm:grid-cols-3">
<div className="space-y-1">
<Label>{t('schedRep.frequency', 'How often')}</Label>
<Select value={s.frequency} onValueChange={(v) => setSchedule({ frequency: v as Frequency })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="daily">{t('schedRep.freqDaily', 'Every day')}</SelectItem>
<SelectItem value="weekly">{t('schedRep.freqWeekly', 'Every week')}</SelectItem>
<SelectItem value="monthly">{t('schedRep.freqMonthly', 'Every month')}</SelectItem>
</SelectContent>
</Select>
</div>
{s.frequency === 'weekly' && (
<div className="space-y-1">
<Label>{t('schedRep.weekday', 'On')}</Label>
<Select value={String(s.weekday)} onValueChange={(v) => setSchedule({ weekday: Number(v) })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{weekdays.map((d) => (
<SelectItem key={d.value} value={d.value}>
{d.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{s.frequency === 'monthly' && (
<div className="space-y-1">
<Label htmlFor="rep-dom">{t('schedRep.dayOfMonth', 'On day (1–28)')}</Label>
<Input
id="rep-dom"
type="number"
min={1}
max={28}
value={s.dayOfMonth}
onChange={(e) =>
setSchedule({ dayOfMonth: Math.min(28, Math.max(1, Number(e.target.value) || 1)) })
}
/>
</div>
)}
<div className="space-y-1">
<Label htmlFor="rep-time">{t('schedRep.time', 'At')}</Label>
<Input
id="rep-time"
type="time"
value={`${String(s.hour).padStart(2, '0')}:${String(s.minute).padStart(2, '0')}`}
onChange={(e) => {
const [h, m] = e.target.value.split(':').map(Number);
if (!Number.isNaN(h) && !Number.isNaN(m)) setSchedule({ hour: h, minute: m });
}}
/>
</div>
</div>
<div className="space-y-1">
<Label>{t('schedRep.timeZone', 'Time zone')}</Label>
<Combobox
options={zones}
value={s.timeZone}
onValueChange={(v) => v && setSchedule({ timeZone: v })}
searchPlaceholder={t('schedRep.zoneSearch', 'Search time zones…')}
/>
</div>
</fieldset>
<div className="space-y-1">
<Label htmlFor="rep-to">{t('schedRep.recipients', 'Who gets it')}</Label>
{builtIn ? (
<p className="text-sm text-muted-foreground">
{t('schedRep.digestRecipients', 'The system administrators, kept up to date as they change.')}
</p>
) : (
<>
<Textarea
id="rep-to"
rows={3}
value={recipientText}
placeholder="[email protected]"
onChange={(e) => setRecipientText(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
{t(
'schedRep.recipientsHint',
'One address per line, up to 50. Accounts on this server only: reports never leave it.',
)}
</p>
</>
)}
</div>
<label className="flex items-center justify-between gap-3 text-sm">
<span>
<span className="font-medium">{t('schedRep.attachCsv', 'Attach CSV files')}</span>
<span className="block text-muted-foreground">
{t('schedRep.attachCsvHint', 'The detail behind each section, for a spreadsheet.')}
</span>
</span>
<Switch checked={input.attachCsv} onCheckedChange={(v) => setInput((i) => ({ ...i, attachCsv: v }))} />
</label>
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onClose(false)}>
{t('common.cancel', 'Cancel')}
</Button>
<Button type="button" disabled={saving || !input.name.trim()} onClick={() => void save()}>
{saving && <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />}
{report ? t('schedRep.save', 'Save') : t('schedRep.create', 'Create report')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,375 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* inbuxa: Reports › Scheduled (scheduled-reports spec). Each report with its
* schedule in words, who gets it, what it covers, when it goes next and how
* the last run went; Send now, Download and Undo-able Delete. The weekly
* digest comes first. Server administrators also set who reports come from.
*/
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { TFunction } from 'i18next';
import { AlertTriangle, Download, Loader2, Pencil, Plus, Send, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { PageHeader } from '@/components/common/PageHeader';
import { LoadingFallback } from '@/components/common/LoadingFallback';
import { toast } from '@/hooks/use-toast';
import { useAccountStore } from '@/stores/accountStore';
import { isPendingDelete, scheduleDelete, UNDO_MS, usePendingDeletes } from '@/lib/pendingDeletes';
import { cn } from '@/lib/utils';
import {
destroyReports,
fetchReports,
fetchSettings,
ScheduledReportsUnavailable,
updateReport,
updateSettings,
type ReportSettings,
type ScheduledReport,
} from './api';
import { isFailing, scheduleText } from './describe';
import { useSectionLabels } from './sections';
import { ReportEditor } from './ReportEditor';
import { DownloadDialog } from './DownloadDialog';
const OBJECT = 'inbuxa:ScheduledReport';
function when(iso: string | null | undefined): string {
if (!iso) return '';
return new Date(iso).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
}
function ReportCard({
report,
canUpdate,
onEdit,
onDownload,
onChanged,
onDelete,
}: {
report: ScheduledReport;
canUpdate: boolean;
onEdit: () => void;
onDownload: () => void;
onChanged: () => void;
onDelete: () => void;
}) {
const { t, i18n } = useTranslation();
const labels = useSectionLabels();
const [busy, setBusy] = useState(false);
const last = report.runs[0];
const failing = isFailing(report);
const toggle = async (enabled: boolean) => {
setBusy(true);
try {
await updateReport(report.id, { enabled });
onChanged();
} catch (e) {
toast({ variant: 'destructive', title: t('schedRep.notSaved', 'Not saved'), description: String(e) });
} finally {
setBusy(false);
}
};
const sendNow = async () => {
setBusy(true);
try {
await updateReport(report.id, { sendNow: true });
toast({
title: t('schedRep.sending', 'Sending {{name}}', { name: report.name }),
description: t('schedRep.sendingHint', 'It shows under Last run in a moment.'),
});
setTimeout(onChanged, 3000);
} catch (e) {
toast({ variant: 'destructive', title: t('schedRep.notSent', 'Not sent'), description: String(e) });
} finally {
setBusy(false);
}
};
return (
<article className={cn('space-y-3 rounded-xl border bg-card p-4 shadow-soft', !report.enabled && 'opacity-80')}>
<div className="flex flex-wrap items-start gap-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h2 className="font-medium">{report.name}</h2>
{report.builtIn && (
<span className="rounded-full border px-2 py-0.5 text-xs text-muted-foreground">
{t('schedRep.builtIn', 'Built in')}
</span>
)}
</div>
<p className="text-sm text-muted-foreground">{scheduleText(report.schedule, t, i18n.language)}</p>
<p className="text-sm text-muted-foreground">
{report.builtIn
? t('schedRep.toAdmins', 'To the system administrators')
: t('schedRep.to', 'To {{who}}', {
who:
report.recipients.slice(0, 3).join(', ') +
(report.recipients.length > 3 ? ` +${report.recipients.length - 3}` : ''),
})}
{report.attachCsv && ` · ${t('schedRep.withCsv', 'with CSV files')}`}
</p>
</div>
<label className="flex items-center gap-2 text-sm">
<Switch
checked={report.enabled}
disabled={!canUpdate || busy}
onCheckedChange={(v) => void toggle(v)}
aria-label={t('schedRep.enabled', 'Send {{name}}', { name: report.name })}
/>
{report.enabled ? t('schedRep.on', 'On') : t('schedRep.off', 'Off')}
</label>
</div>
<div className="flex flex-wrap gap-1.5">
{report.sections.map((s) => (
<span key={s} className="rounded-md bg-muted px-2 py-0.5 text-xs">
{labels[s].label}
</span>
))}
</div>
{failing && (
<p className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/5 p-2 text-sm">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
{t('schedRep.failing', 'The last two runs failed: {{reason}}', { reason: last?.reason ?? '' })}
</p>
)}
<dl className="grid gap-x-6 gap-y-1 text-sm sm:grid-cols-2">
<div>
<dt className="text-xs text-muted-foreground">{t('schedRep.next', 'Next')}</dt>
<dd>{report.enabled ? when(report.nextRunAt) : t('schedRep.notScheduled', 'Off, so not scheduled')}</dd>
</div>
<div>
<dt className="text-xs text-muted-foreground">{t('schedRep.last', 'Last run')}</dt>
<dd>
{!last
? t('schedRep.never', 'Not yet')
: last.status === 'sent'
? t('schedRep.lastSent', '{{when}}, to {{count}}', {
when: when(last.at),
count: last.recipients,
}) + (last.byHand ? ` ${t('schedRep.byHand', '(sent by hand)')}` : '')
: t('schedRep.lastFailed', '{{when}}, failed: {{reason}}', {
when: when(last.at),
reason: last.reason ?? '',
})}
</dd>
</div>
</dl>
<div className="flex flex-wrap gap-2">
{canUpdate && (
<Button type="button" variant="outline" size="sm" onClick={onEdit}>
<Pencil className="mr-1.5 h-4 w-4" />
{t('schedRep.edit', 'Edit')}
</Button>
)}
{canUpdate && (
<Button type="button" variant="outline" size="sm" disabled={busy} onClick={() => void sendNow()}>
{busy ? <Loader2 className="mr-1.5 h-4 w-4 animate-spin" /> : <Send className="mr-1.5 h-4 w-4" />}
{t('schedRep.sendNow', 'Send now')}
</Button>
)}
<Button type="button" variant="outline" size="sm" onClick={onDownload}>
<Download className="mr-1.5 h-4 w-4" />
{t('schedRep.download', 'Download')}
</Button>
{canUpdate && !report.builtIn && (
<Button type="button" variant="ghost" size="sm" className="text-destructive" onClick={onDelete}>
<Trash2 className="mr-1.5 h-4 w-4" />
{t('schedRep.delete', 'Delete')}
</Button>
)}
</div>
</article>
);
}
function SenderSettings({ settings, onSaved }: { settings: ReportSettings; onSaved: () => void }) {
const { t } = useTranslation();
const [name, setName] = useState(settings.fromName);
const [address, setAddress] = useState(settings.fromAddress);
const [saving, setSaving] = useState(false);
const changed = name !== settings.fromName || address !== settings.fromAddress;
const save = async () => {
setSaving(true);
try {
await updateSettings({ fromName: name, fromAddress: address });
toast({ title: t('schedRep.senderSaved', 'Saved'), variant: 'success' });
onSaved();
} catch (e) {
toast({ variant: 'destructive', title: t('schedRep.notSaved', 'Not saved'), description: String(e) });
} finally {
setSaving(false);
}
};
return (
<section className="space-y-3 rounded-xl border bg-card p-4 shadow-soft">
<div>
<h2 className="font-medium">{t('schedRep.senderTitle', 'Who reports come from')}</h2>
<p className="text-sm text-muted-foreground">
{t(
'schedRep.senderHint',
'Reports are DKIM-signed with this address’s domain. A domain without a DKIM key can’t send them.',
)}
</p>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-1">
<Label htmlFor="rep-from-name">{t('schedRep.fromName', 'Name')}</Label>
<Input id="rep-from-name" value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div className="space-y-1">
<Label htmlFor="rep-from-address">{t('schedRep.fromAddress', 'Address')}</Label>
<Input id="rep-from-address" value={address} onChange={(e) => setAddress(e.target.value)} />
</div>
</div>
<Button type="button" size="sm" disabled={!changed || saving} onClick={() => void save()}>
{t('schedRep.save', 'Save')}
</Button>
</section>
);
}
function removeWithUndo(report: ScheduledReport, t: TFunction) {
const scheduled = scheduleDelete(OBJECT, [report.id], async (_object, ids) => {
const { destroyed, errors } = await destroyReports(ids);
return {
destroyed,
errors: Object.fromEntries(
Object.entries(errors).map(([id, description]) => [id, { type: 'refused', description }]),
),
};
});
const { dismiss } = toast({
title: t('undoDelete.deleted', 'Deleted {{what}}', { what: report.name }),
duration: UNDO_MS,
description: (
<Button
type="button"
variant="outline"
size="sm"
className="mt-1"
onClick={() => {
scheduled.undo();
dismiss();
}}
>
{t('undoDelete.undo', 'Undo')}
</Button>
),
});
void scheduled.settled.then((result) => {
const why = result && Object.values(result.errors)[0]?.description;
if (why) toast({ variant: 'destructive', title: t('schedRep.notDeleted', 'Not deleted'), description: why });
});
}
export function ScheduledReportsPage() {
const { t } = useTranslation();
const canUpdate = useAccountStore((s) => s.hasPermission('sysScheduledReportUpdate'));
const [reports, setReports] = useState<ScheduledReport[] | null>(null);
const [settings, setSettings] = useState<ReportSettings | null>(null);
const [error, setError] = useState<string | null>(null);
const [unavailable, setUnavailable] = useState(false);
const [editing, setEditing] = useState<ScheduledReport | 'new' | null>(null);
const [downloading, setDownloading] = useState<ScheduledReport | null>(null);
const pendingVersion = usePendingDeletes((s) => s.versions[OBJECT] ?? 0);
const load = useCallback(async () => {
try {
const [list, s] = await Promise.all([fetchReports(), fetchSettings()]);
setReports(list);
setSettings(s);
setError(null);
} catch (e) {
if (e instanceof ScheduledReportsUnavailable) setUnavailable(true);
else setError(e instanceof Error ? e.message : String(e));
}
}, []);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
void load();
}, [load, pendingVersion]);
const shown = useMemo(() => (reports ?? []).filter((r) => !isPendingDelete(OBJECT, r.id)), [reports, pendingVersion]); // eslint-disable-line react-hooks/exhaustive-deps
// Only server administrators see the digest, so it marks who may set the sender
const serverLevel = (reports ?? []).some((r) => r.builtIn);
if (unavailable) {
return (
<p className="rounded-lg border border-dashed p-8 text-center text-muted-foreground">
{t('schedRep.unavailable', 'This server doesn’t have scheduled reports yet. They come with a server update.')}
</p>
);
}
if (error) return <p className="text-sm text-destructive">{error}</p>;
if (!reports || !settings) return <LoadingFallback />;
return (
<div className="space-y-5">
<div className="flex flex-wrap items-start justify-between gap-4">
<PageHeader
icon="calendar-clock"
title={t('schedRep.title', 'Scheduled reports')}
subtitle={t(
'schedRep.subtitle',
'Reports the server builds from what it already keeps, and mails to accounts here. The weekly digest is built in.',
)}
/>
{canUpdate && (
<Button type="button" onClick={() => setEditing('new')}>
<Plus className="mr-2 h-4 w-4" />
{t('schedRep.new', 'New report')}
</Button>
)}
</div>
<div className="space-y-3">
{shown.map((report) => (
<ReportCard
key={report.id}
report={report}
canUpdate={canUpdate}
onEdit={() => setEditing(report)}
onDownload={() => setDownloading(report)}
onChanged={() => void load()}
onDelete={() => removeWithUndo(report, t)}
/>
))}
{shown.length === 0 && (
<p className="rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground">
{t('schedRep.none', 'No reports yet. Make one for your own tenant’s domains and people.')}
</p>
)}
</div>
{serverLevel && canUpdate && <SenderSettings settings={settings} onSaved={() => void load()} />}
{editing && (
<ReportEditor
report={editing === 'new' ? null : editing}
tenant={!serverLevel}
onClose={(saved) => {
setEditing(null);
if (saved) void load();
}}
/>
)}
{downloading && <DownloadDialog report={downloading} onClose={() => setDownloading(null)} />}
</div>
);
}
+171
View File
@@ -0,0 +1,171 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* inbuxa: scheduled reports and the weekly digest (scheduled-reports spec):
* `inbuxa:ScheduledReport`, `inbuxa:ScheduledReportSettings` and the
* download, `inbuxa:ReportExport`.
*/
import { apiFetch } from '@/services/api';
import { fetchSession, getAccountId, jmapRequest } from '@/services/jmap/client';
import { INBUXA_CAPABILITY } from '@/features/hardening/protocolPolicy';
export type SectionName =
'mailFlow' | 'queue' | 'spoofing' | 'tlsFailures' | 'deliverability' | 'security' | 'storage' | 'certificates';
export type Frequency = 'daily' | 'weekly' | 'monthly';
export interface Schedule {
frequency: Frequency;
/** 1 = Monday … 7 = Sunday. */
weekday: number;
dayOfMonth: number;
hour: number;
minute: number;
timeZone: string;
}
export interface Run {
at: string;
byHand: boolean;
status: 'sent' | 'failed';
reason?: string | null;
recipients: number;
size: number;
}
export interface ScheduledReport {
id: string;
name: string;
enabled: boolean;
builtIn: boolean;
sections: SectionName[];
schedule: Schedule;
recipients: string[];
attachCsv: boolean;
memberTenantId: string | null;
createdAt: string;
nextRunAt: string | null;
runs: Run[];
}
export type ReportInput = Pick<ScheduledReport, 'name' | 'sections' | 'schedule' | 'recipients' | 'attachCsv'> & {
enabled?: boolean;
};
export interface ReportSettings {
fromName: string;
fromAddress: string;
}
/** The server doesn't have scheduled reports (an older version). */
export class ScheduledReportsUnavailable extends Error {}
async function call(method: string, args: Record<string, unknown>): Promise<Record<string, unknown>> {
const responses = await jmapRequest([[method, { accountId: getAccountId('x:Domain'), ...args }, '0']], undefined, [
INBUXA_CAPABILITY,
]);
const [name, result] = responses[0] ?? [];
if (name !== method) {
const r = result as { type?: string; description?: string } | undefined;
if (r?.type === 'unknownMethod' || r?.type === 'unknownCapability') throw new ScheduledReportsUnavailable(r.type);
throw new Error(r?.description ?? r?.type ?? 'Request failed');
}
return result as Record<string, unknown>;
}
type SetFailure = Record<string, { description?: string; type?: string } | undefined> | undefined;
function failed(map: SetFailure, key: string): string | null {
const f = map?.[key];
return f ? (f.description ?? f.type ?? 'Refused') : null;
}
export async function fetchReports(): Promise<ScheduledReport[]> {
const result = await call('inbuxa:ScheduledReport/get', { ids: null });
return ((result.list as ScheduledReport[]) ?? []).sort(
(a, b) => Number(b.builtIn) - Number(a.builtIn) || a.name.localeCompare(b.name),
);
}
export async function createReport(input: ReportInput): Promise<string> {
const result = await call('inbuxa:ScheduledReport/set', { create: { new: input } });
const created = (result.created as Record<string, { id: string }> | undefined)?.new;
if (!created) throw new Error(failed(result.notCreated as SetFailure, 'new') ?? 'Not created');
return created.id;
}
export async function updateReport(id: string, patch: Partial<ReportInput> & { sendNow?: boolean }): Promise<void> {
const result = await call('inbuxa:ScheduledReport/set', { update: { [id]: patch } });
const why = failed(result.notUpdated as SetFailure, id);
if (why) throw new Error(why);
}
export async function destroyReports(ids: string[]): Promise<{ destroyed: string[]; errors: Record<string, string> }> {
const result = await call('inbuxa:ScheduledReport/set', { destroy: ids });
const errors: Record<string, string> = {};
for (const id of ids) {
const why = failed(result.notDestroyed as SetFailure, id);
if (why) errors[id] = why;
}
return { destroyed: (result.destroyed as string[]) ?? [], errors };
}
export async function fetchSettings(): Promise<ReportSettings> {
const result = await call('inbuxa:ScheduledReportSettings/get', { ids: null });
return (result.list as ReportSettings[] | undefined)?.[0] ?? { fromName: '', fromAddress: '' };
}
export async function updateSettings(patch: Partial<ReportSettings>): Promise<void> {
const result = await call('inbuxa:ScheduledReportSettings/set', { update: { singleton: patch } });
const why = failed(result.notUpdated as SetFailure, 'singleton');
if (why) throw new Error(why);
}
/** RP-19: builds the report for a period and saves it as a ZIP. */
export async function downloadReport(report: ScheduledReport, from?: Date, to?: Date): Promise<void> {
const create: Record<string, unknown> = { reportId: report.id };
if (from) create.from = from.toISOString().replace(/\.\d+Z$/, 'Z');
if (to) create.to = to.toISOString().replace(/\.\d+Z$/, 'Z');
const result = await call('inbuxa:ReportExport/set', { create: { x: create } });
const created = (result.created as Record<string, { blobId: string }> | undefined)?.x;
if (!created) throw new Error(failed(result.notCreated as SetFailure, 'x') ?? 'Not built');
const slug =
report.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '') || 'report';
const fileName = `${slug}-${new Date().toISOString().slice(0, 10)}.zip`;
const session = await fetchSession();
const template =
typeof session.downloadUrl === 'string'
? session.downloadUrl
: '/jmap/download/{accountId}/{blobId}/{name}?accept={type}';
const url = template
.replace('{accountId}', encodeURIComponent(getAccountId('x:Domain')))
.replace('{blobId}', encodeURIComponent(created.blobId))
.replace('{name}', encodeURIComponent(fileName))
.replace('{type}', encodeURIComponent('application/zip'));
let path = url;
try {
const u = new URL(url, 'http://placeholder');
path = u.pathname + u.search;
} catch {
// a relative template is already a path
}
const response = await apiFetch(path);
if (!response.ok) throw new Error(`Download failed (${response.status})`);
const href = URL.createObjectURL(await response.blob());
const a = document.createElement('a');
a.href = href;
a.download = fileName;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(href), 10_000);
}
@@ -0,0 +1,74 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { describe, expect, it } from 'vitest';
import type { Run, ScheduledReport } from './api';
import { isFailing, newReport, parseRecipients, scheduleText, timeZones } from './describe';
const t = (_key: string, fallback: string, options?: Record<string, unknown>) =>
fallback.replace(/\{\{(\w+)\}\}/g, (_m, k: string) => String(options?.[k] ?? ''));
const base = { weekday: 1, dayOfMonth: 1, hour: 7, minute: 5, timeZone: 'UTC' };
describe('scheduleText', () => {
it('reads the three frequencies', () => {
expect(scheduleText({ ...base, frequency: 'daily' }, t, 'en-US')).toBe('Every day at 07:05 (UTC)');
expect(scheduleText({ ...base, frequency: 'weekly' }, t, 'en-US')).toBe('Every Monday at 07:05 (UTC)');
expect(scheduleText({ ...base, frequency: 'weekly', weekday: 7 }, t, 'en-US')).toBe('Every Sunday at 07:05 (UTC)');
expect(scheduleText({ ...base, frequency: 'monthly', dayOfMonth: 28 }, t, 'en-US')).toBe(
'Day 28 of each month at 07:05 (UTC)',
);
});
});
function report(runs: Partial<Run>[], enabled = true): ScheduledReport {
return {
id: 'a',
name: 'R',
enabled,
builtIn: false,
sections: ['storage'],
schedule: { ...base, frequency: 'daily' },
recipients: [],
attachCsv: false,
memberTenantId: null,
createdAt: '',
nextRunAt: null,
runs: runs.map((r) => ({ at: '', byHand: false, status: 'sent', recipients: 1, size: 1, ...r })),
};
}
describe('isFailing', () => {
it('needs the last two scheduled runs to have failed', () => {
expect(isFailing(report([{ status: 'failed' }, { status: 'failed' }]))).toBe(true);
expect(isFailing(report([{ status: 'failed' }, { status: 'sent' }]))).toBe(false);
expect(isFailing(report([{ status: 'failed' }]))).toBe(false);
// A failed Send now in between doesn't count
expect(isFailing(report([{ status: 'failed', byHand: true }, { status: 'failed' }, { status: 'sent' }]))).toBe(
false,
);
// Off reports don't nag
expect(isFailing(report([{ status: 'failed' }, { status: 'failed' }], false))).toBe(false);
});
});
describe('helpers', () => {
it('tidies recipients', () => {
expect(parseRecipients(' [email protected],\[email protected]; [email protected] ')).toEqual([
'[email protected]',
'[email protected]',
]);
});
it('keeps an unknown zone selectable and starts new reports sensibly', () => {
expect(timeZones('Mars/Olympus')[0]).toBe('Mars/Olympus');
expect(timeZones('UTC')).toContain('UTC');
const r = newReport('[email protected]', 'Europe/Amsterdam');
expect(r.recipients).toEqual(['[email protected]']);
expect(r.sections).toHaveLength(8);
expect(r.schedule.timeZone).toBe('Europe/Amsterdam');
});
});
+96
View File
@@ -0,0 +1,96 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/** inbuxa: how scheduled reports read on the page (scheduled-reports spec). */
import type { ReportInput, Schedule, ScheduledReport, SectionName } from './api';
type TFn = (key: string, fallback: string, options?: Record<string, unknown>) => string;
/** In the order the mail shows them. */
export const SECTIONS: SectionName[] = [
'mailFlow',
'queue',
'spoofing',
'tlsFailures',
'deliverability',
'security',
'storage',
'certificates',
];
/** RP-12: server-wide, so a tenant's report leaves them out. */
export const SERVER_WIDE = new Set<SectionName>(['mailFlow', 'queue', 'security', 'certificates']);
const pad = (n: number) => String(n).padStart(2, '0');
/** "Mondays at 07:00 (UTC)". Weekday names come from the browser's locale. */
export function scheduleText(s: Schedule, t: TFn, locale?: string): string {
const time = `${pad(s.hour)}:${pad(s.minute)}`;
const zone = s.timeZone;
if (s.frequency === 'daily') return t('sched.daily', 'Every day at {{time}} ({{zone}})', { time, zone });
if (s.frequency === 'monthly') {
return t('sched.monthly', 'Day {{day}} of each month at {{time}} ({{zone}})', { day: s.dayOfMonth, time, zone });
}
// 2026-10-05 is a Monday; weekday 1 = Monday
const day = new Date(Date.UTC(2026, 9, 4 + s.weekday)).toLocaleDateString(locale, {
weekday: 'long',
timeZone: 'UTC',
});
return t('sched.weekly', 'Every {{day}} at {{time}} ({{zone}})', { day, time, zone });
}
/** RP-17: the last two scheduled runs both failed. */
export function isFailing(report: ScheduledReport): boolean {
const scheduled = report.runs.filter((r) => !r.byHand);
return (
report.enabled && scheduled.length >= 2 && scheduled[0].status === 'failed' && scheduled[1].status === 'failed'
);
}
export function browserTimeZone(): string {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
} catch {
return 'UTC';
}
}
/** Every IANA zone the browser knows, with the given one first if missing. */
export function timeZones(current: string): string[] {
let zones: string[];
try {
zones = (Intl as unknown as { supportedValuesOf?: (k: string) => string[] }).supportedValuesOf?.('timeZone') ?? [];
} catch {
zones = [];
}
if (!zones.includes('UTC')) zones = ['UTC', ...zones];
return zones.includes(current) ? zones : [current, ...zones];
}
/** A new report's starting point: a weekly summary of everything, to me. */
export function newReport(me: string | null, zone: string): ReportInput {
return {
name: '',
sections: [...SECTIONS],
schedule: { frequency: 'weekly', weekday: 1, dayOfMonth: 1, hour: 8, minute: 0, timeZone: zone },
recipients: me ? [me] : [],
attachCsv: false,
enabled: true,
};
}
/** Addresses typed one per line or separated by commas, tidied. */
export function parseRecipients(text: string): string[] {
return [
...new Set(
text
.split(/[\s,;]+/)
.map((a) => a.trim().toLowerCase())
.filter(Boolean),
),
];
}
+63
View File
@@ -0,0 +1,63 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/** inbuxa: each report section's name and what it contains (RP-1 to RP-8). */
import { useTranslation } from 'react-i18next';
import type { SectionName } from './api';
export function useSectionLabels(): Record<SectionName, { label: string; hint: string }> {
const { t } = useTranslation();
return {
mailFlow: {
label: t('schedRep.s.mailFlow', 'Mail flow'),
hint: t(
'schedRep.s.mailFlowHint',
'Messages received, sent, delivered as spam and bounced, and the average delivery time.',
),
},
queue: {
label: t('schedRep.s.queue', 'Queue'),
hint: t('schedRep.s.queueHint', 'Messages still waiting to be delivered at the end of the period.'),
},
spoofing: {
label: t('schedRep.s.spoofing', 'Mail pretending to be you'),
hint: t(
'schedRep.s.spoofingHint',
'From received DMARC reports: messages that used your domains and couldn’t prove it, and where they came from.',
),
},
tlsFailures: {
label: t('schedRep.s.tlsFailures', 'Secure delivery to you'),
hint: t(
'schedRep.s.tlsFailuresHint',
'From received TLS reports: deliveries to your domains that couldn’t be encrypted, and why.',
),
},
deliverability: {
label: t('schedRep.s.deliverability', 'Deliverability'),
hint: t(
'schedRep.s.deliverabilityHint',
'Blocklists, reverse DNS, SPF, DKIM and MTA-STS problems, and what changed since the last report.',
),
},
security: {
label: t('schedRep.s.security', 'Security'),
hint: t('schedRep.s.securityHint', 'Failed sign-ins, bans and blocked addresses.'),
},
storage: {
label: t('schedRep.s.storage', 'Storage'),
hint: t(
'schedRep.s.storageHint',
'People at 90% or more of their storage, with their addresses, and people and domains added or removed.',
),
},
certificates: {
label: t('schedRep.s.certificates', 'Certificates'),
hint: t('schedRep.s.certificatesHint', 'Certificates that expire within 21 days.'),
},
};
}
@@ -0,0 +1,34 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs LLC
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* inbuxa: how many scheduled reports failed their last two runs, for the
* dashboard's Needs attention (scheduled-reports spec, RP-17). Only for
* whoever may see the reports; null until known, or on an older server.
*/
import { useEffect, useState } from 'react';
import { useAccountStore } from '@/stores/accountStore';
import { fetchReports } from './api';
import { isFailing } from './describe';
export function useReportFailures(tick: number): number | null {
const allowed = useAccountStore((s) => s.hasObjectPermission('sysScheduledReport', 'Get'));
const [count, setCount] = useState<number | null>(null);
useEffect(() => {
if (!allowed) return;
let live = true;
fetchReports()
.then((reports) => live && setCount(reports.filter(isFailing).length))
.catch(() => live && setCount(null));
return () => {
live = false;
};
}, [allowed, tick]);
return allowed ? count : null;
}
+96 -1
View File
@@ -1795,7 +1795,10 @@
"feedDetail": "Counts come from the server’s records; connection meters are dark.",
"deliverability_one": "blocklist or reverse-DNS problem",
"deliverability_other": "blocklist or reverse-DNS problems",
"deliverabilityDetail": "Other mail servers may refuse mail from this one."
"deliverabilityDetail": "Other mail servers may refuse mail from this one.",
"scheduledReports_one": "scheduled report keeps failing",
"scheduledReports_other": "scheduled reports keep failing",
"scheduledReportsDetail": "Their last two runs failed; the report page says why."
},
"attention_one": "Needs attention · {{count}}",
"attention_other": "Needs attention · {{count}}",
@@ -2217,5 +2220,97 @@
"body": "{{name}} is on the server. Once its DNS records are published, mail for it starts arriving here.",
"dns": "Set up its DNS",
"person": "Add the first person"
},
"schedRep": {
"dlLast": "Its last full period",
"dl7": "The last 7 days",
"dl30": "The last 30 days",
"dlCustom": "Pick dates",
"dlTitle": "Download {{name}}",
"dlHint": "A ZIP with the summary and a CSV for each section that has rows. Nobody is mailed. Up to 90 days back.",
"dlFrom": "From",
"dlTo": "To",
"dlGo": "Download ZIP",
"editTitle": "Edit {{name}}",
"newTitle": "New report",
"editHint": "Built when it’s due, from what the server already keeps. Empty sections are left out.",
"name": "Name",
"namePlaceholder": "e.g. Monday morning summary",
"sections": "What it covers",
"serverWide": "Server-wide, so not in a tenant’s report.",
"when": "When",
"frequency": "How often",
"freqDaily": "Every day",
"freqWeekly": "Every week",
"freqMonthly": "Every month",
"weekday": "On",
"dayOfMonth": "On day (1–28)",
"time": "At",
"timeZone": "Time zone",
"zoneSearch": "Search time zones…",
"recipients": "Who gets it",
"digestRecipients": "The system administrators, kept up to date as they change.",
"recipientsHint": "One address per line, up to 50. Accounts on this server only: reports never leave it.",
"attachCsv": "Attach CSV files",
"attachCsvHint": "The detail behind each section, for a spreadsheet.",
"save": "Save",
"create": "Create report",
"notSaved": "Not saved",
"sending": "Sending {{name}}",
"sendingHint": "It shows under Last run in a moment.",
"notSent": "Not sent",
"builtIn": "Built in",
"toAdmins": "To the system administrators",
"to": "To {{who}}",
"withCsv": "with CSV files",
"enabled": "Send {{name}}",
"on": "On",
"off": "Off",
"failing": "The last two runs failed: {{reason}}",
"next": "Next",
"notScheduled": "Off, so not scheduled",
"last": "Last run",
"never": "Not yet",
"lastSent": "{{when}}, to {{count}}",
"byHand": "(sent by hand)",
"lastFailed": "{{when}}, failed: {{reason}}",
"edit": "Edit",
"sendNow": "Send now",
"download": "Download",
"delete": "Delete",
"senderSaved": "Saved",
"senderTitle": "Who reports come from",
"senderHint": "Reports are DKIM-signed with this address’s domain. A domain without a DKIM key can’t send them.",
"fromName": "Name",
"fromAddress": "Address",
"notDeleted": "Not deleted",
"unavailable": "This server doesn’t have scheduled reports yet. They come with a server update.",
"title": "Scheduled reports",
"subtitle": "Reports the server builds from what it already keeps, and mails to accounts here. The weekly digest is built in.",
"new": "New report",
"none": "No reports yet. Make one for your own tenant’s domains and people.",
"s": {
"mailFlow": "Mail flow",
"mailFlowHint": "Messages received, sent, delivered as spam and bounced, and the average delivery time.",
"queue": "Queue",
"queueHint": "Messages still waiting to be delivered at the end of the period.",
"spoofing": "Mail pretending to be you",
"spoofingHint": "From received DMARC reports: messages that used your domains and couldn’t prove it, and where they came from.",
"tlsFailures": "Secure delivery to you",
"tlsFailuresHint": "From received TLS reports: deliveries to your domains that couldn’t be encrypted, and why.",
"deliverability": "Deliverability",
"deliverabilityHint": "Blocklists, reverse DNS, SPF, DKIM and MTA-STS problems, and what changed since the last report.",
"security": "Security",
"securityHint": "Failed sign-ins, bans and blocked addresses.",
"storage": "Storage",
"storageHint": "People at 90% or more of their storage, with their addresses, and people and domains added or removed.",
"certificates": "Certificates",
"certificatesHint": "Certificates that expire within 21 days."
}
},
"sched": {
"daily": "Every day at {{time}} ({{zone}})",
"monthly": "Day {{day}} of each month at {{time}} ({{zone}})",
"weekly": "Every {{day}} at {{time}} ({{zone}})"
}
}
+4
View File
@@ -119,6 +119,10 @@ function checkSpecialLink(
if (viewName === 'CustomComponent/Deliverability') {
return { visible: canGet ? canGet('sysDeliverability') : true, enterprise: false };
}
// inbuxa: Reports › Scheduled, for whoever may see the reports (scheduled-reports spec)
if (viewName === 'CustomComponent/ScheduledReports') {
return { visible: canGet ? canGet('sysScheduledReport') : true, enterprise: false };
}
// INBUXA: a page this console can't draw is hidden. A server newer than the
// console lists pages the console doesn't know yet, and counting them as