Reports › Scheduled: scheduled reports and the weekly digest #93
No files matched your search
@@ -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 />;
|
||||
}
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
),
|
||||
];
|
||||
}
|
||||
@@ -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
@@ -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}})"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user