feat(server): refactor mail queue (#15204)

This commit is contained in:
DarkSky
2026-07-07 08:38:16 +08:00
committed by GitHub
parent 9581432d21
commit 998b255afd
72 changed files with 8386 additions and 763 deletions
+28
View File
@@ -50,6 +50,10 @@
"queues.backendRuntime": {
"type": "Object",
"desc": "The config for backend runtime job queue"
},
"queues.inviteAbuse": {
"type": "Object",
"desc": "The config for invite abuse disposition job queue"
}
},
"throttle": {
@@ -87,6 +91,18 @@
"type": "Number",
"desc": "Minimum account age in seconds before new accounts can invite members or create share links."
},
"trustedCloudflareHeaders": {
"type": "Boolean",
"desc": "Whether request abuse source facts should trust Cloudflare headers from the origin edge."
},
"inviteQuotaShadowMode": {
"type": "Boolean",
"desc": "Whether workspace invite quota should record would-block decisions without rejecting requests or executing abuse actions."
},
"inviteQuotaFailOpenOnRuntimeError": {
"type": "Boolean",
"desc": "Whether workspace invite quota should fail open when native runtime admission is unavailable. Keep disabled for production."
},
"passwordRequirements": {
"type": "Object",
"desc": "The password strength requirements when set new password."
@@ -140,6 +156,18 @@
"type": "Array",
"desc": "The emails from these domains are always sent using the fallback SMTP server."
},
"deliveryWorker.batchSize": {
"type": "Number",
"desc": "Number of mail delivery rows claimed by each worker tick."
},
"deliveryWorker.leaseMs": {
"type": "Number",
"desc": "Mail delivery worker lease duration in milliseconds."
},
"deliveryWorker.retentionDays": {
"type": "Number",
"desc": "Days to retain anonymized terminal mail delivery ledger rows."
},
"fallbackSMTP.name": {
"type": "String",
"desc": "Hostname used for fallback SMTP HELO/EHLO (e.g. mail.example.com). Leave empty to use the system hostname."
@@ -87,16 +87,91 @@ const dashboardData = {
},
};
const mailDeliveryData = {
adminMailDeliveries: {
window: {
from: '2026-02-15T20:00:00.000Z',
to: '2026-02-16T20:00:00.000Z',
timezone: 'UTC',
bucket: 'Hour',
requestedSize: 24,
effectiveSize: 24,
},
summary: {
total: 4,
sent: 2,
failed: 1,
skipped: 0,
canceled: 0,
queued: 1,
sending: 0,
retryWait: 0,
successRate: 2 / 3,
},
byStatus: [
{
key: 'sent',
label: 'Sent',
total: 2,
points: [{ bucket: '2026-02-16T19:00:00.000Z', count: 2 }],
},
{
key: 'failed',
label: 'Failed',
total: 1,
points: [{ bucket: '2026-02-16T19:00:00.000Z', count: 1 }],
},
{
key: 'queued',
label: 'Queued',
total: 1,
points: [{ bucket: '2026-02-16T19:00:00.000Z', count: 1 }],
},
],
byType: [
{
key: 'auth',
label: 'auth',
total: 2,
points: [{ bucket: '2026-02-16T19:00:00.000Z', count: 2 }],
},
],
byOutcome: [
{
key: 'successful',
label: 'Successful',
total: 2,
points: [{ bucket: '2026-02-16T19:00:00.000Z', count: 2 }],
},
{
key: 'unsuccessful',
label: 'Unsuccessful',
total: 1,
points: [{ bucket: '2026-02-16T19:00:00.000Z', count: 1 }],
},
{
key: 'pending',
label: 'Pending',
total: 1,
points: [{ bucket: '2026-02-16T19:00:00.000Z', count: 1 }],
},
],
},
};
describe('DashboardPage', () => {
beforeEach(() => {
(globalThis as any).environment = {
isSelfHosted: true,
};
useQueryMock.mockReset();
useQueryMock.mockReturnValue({
data: dashboardData,
useQueryMock.mockImplementation(({ query }: { query: { id: string } }) => ({
data:
query.id === 'adminMailDeliveriesQuery'
? mailDeliveryData
: dashboardData,
isValidating: false,
});
}));
mutateQueryResourceMock.mockReset();
});
@@ -125,4 +200,14 @@ describe('DashboardPage', () => {
expect(styles).toContain('--color-secondary: var(--muted-foreground);');
expect(styles).not.toContain('hsl(var(--primary))');
});
test('renders mail delivery analytics controls and summary', () => {
const { getByText } = render(<DashboardPage />);
expect(getByText('Email Delivery Trend')).toBeTruthy();
expect(getByText('Mail Delivery')).toBeTruthy();
expect(getByText('24 hours')).toBeTruthy();
expect(getByText('Success rate')).toBeTruthy();
expect(getByText('66.7%')).toBeTruthy();
});
});
@@ -46,12 +46,17 @@ import {
} from '@affine/admin/components/ui/table';
import { useMutation } from '@affine/admin/use-mutation';
import { useQuery } from '@affine/admin/use-query';
import { adminDashboardQuery, previewLicenseMutation } from '@affine/graphql';
import {
adminDashboardQuery,
adminMailDeliveriesQuery,
previewLicenseMutation,
} from '@affine/graphql';
import { ROUTES } from '@affine/routes';
import {
ChevronDownIcon,
DatabaseIcon,
FileSearchIcon,
MailIcon,
MessageSquareTextIcon,
RefreshCwIcon,
UsersIcon,
@@ -166,6 +171,7 @@ const utcDateFormatter = new Intl.DateTimeFormat('en-US', {
const STORAGE_DAY_OPTIONS = [7, 14, 30, 60, 90] as const;
const SYNC_HOUR_OPTIONS = [1, 6, 12, 24, 48, 72] as const;
const SHARED_DAY_OPTIONS = [7, 14, 28, 60, 90] as const;
type MailChartMode = 'status' | 'type' | 'outcome';
type DualNumberPoint = {
label: string;
@@ -180,6 +186,18 @@ type TrendPoint = {
secondary?: number;
};
type MultiTrendPoint = {
x: number;
label: string;
} & Record<string, number | string>;
type MultiTrendSeries = {
key: string;
label: string;
color: string;
total: number;
};
type LicensePreview = {
id: string;
workspaceId: string;
@@ -362,6 +380,110 @@ function TrendChart({
);
}
function MultiTrendChart({
ariaLabel,
points,
series,
valueFormatter,
}: {
ariaLabel: string;
points: MultiTrendPoint[];
series: MultiTrendSeries[];
valueFormatter: (value: number) => string;
}) {
const visibleSeries = series.filter(item => item.total > 0).slice(0, 4);
if (points.length === 0 || visibleSeries.length === 0) {
return (
<div className="flex h-44 items-center justify-center rounded-lg border border-dashed border-border/60 bg-muted/15 text-sm text-muted-foreground">
No mail deliveries in this window
</div>
);
}
const chartPoints =
points.length === 1
? [points[0], { ...points[0], x: Number(points[0].x) + 1 }]
: points;
const config: ChartConfig = Object.fromEntries(
visibleSeries.map(item => [
item.key,
{
label: item.label,
color: item.color,
},
])
);
return (
<ChartContainer
config={config}
className="h-44 w-full"
aria-label={ariaLabel}
role="img"
>
<LineChart
data={chartPoints}
margin={{ top: 8, right: 0, bottom: 0, left: 0 }}
>
<CartesianGrid
vertical={false}
stroke="var(--border)"
strokeDasharray="3 4"
/>
<XAxis
dataKey="x"
type="number"
hide
allowDecimals={false}
domain={['dataMin', 'dataMax']}
/>
<YAxis
hide
domain={[
0,
(max: number) => {
if (max <= 0) {
return 1;
}
return Math.ceil(max * 1.1);
},
]}
/>
<ChartTooltip
cursor={{
stroke: 'var(--border)',
strokeDasharray: '4 4',
strokeWidth: 1,
}}
content={
<ChartTooltipContent
labelFormatter={(_, payload) => {
const item = payload?.[0];
return item?.payload?.label ?? '';
}}
valueFormatter={value => valueFormatter(value)}
/>
}
/>
{visibleSeries.map(item => (
<Line
key={item.key}
dataKey={item.key}
type="monotone"
stroke={`var(--color-${item.key})`}
strokeWidth={item.key === visibleSeries[0]?.key ? 3 : 2}
dot={false}
activeDot={{ r: 3 }}
connectNulls
isAnimationActive={false}
/>
))}
</LineChart>
</ChartContainer>
);
}
function PrimaryMetricCard({
value,
description,
@@ -458,6 +580,37 @@ function WindowSelect({
);
}
function MailWindowSelect({
value,
onChange,
}: {
value: number;
onChange: (value: number) => void;
}) {
return (
<div className="flex min-w-0 flex-col gap-2">
<Label
htmlFor="mail-delivery-window"
className="text-xs uppercase tracking-wide text-muted-foreground"
>
Mail Delivery
</Label>
<Select
value={String(value)}
onValueChange={next => onChange(Number(next))}
>
<SelectTrigger id="mail-delivery-window">
<SelectValue placeholder="Select mail window…" />
</SelectTrigger>
<SelectContent>
<SelectItem value="24">24 hours</SelectItem>
<SelectItem value="168">7 days</SelectItem>
</SelectContent>
</Select>
</div>
);
}
function LicensePreviewDialog({
license,
onOpenChange,
@@ -830,10 +983,244 @@ function TopSharedLinksSection({
);
}
function mailWindowLabel(hours: number) {
return hours === 24 ? '24h' : '7d';
}
function mailBucketLabel(value: string, hours: number) {
return hours === 24 ? formatDateTime(value) : formatDate(value);
}
function toMailChartPoints(
series: {
key: string;
points: { bucket: string; count: number }[];
}[],
hours: number
) {
const first = series[0]?.points ?? [];
return first.map((point, index) => {
const row: MultiTrendPoint = {
x: index,
label: mailBucketLabel(point.bucket, hours),
};
for (const item of series) {
row[item.key] = item.points[index]?.count ?? 0;
}
return row;
});
}
function seriesColor(key: string, index: number) {
const colors: Record<string, string> = {
sent: 'var(--primary)',
failed: 'var(--destructive)',
retry_wait: 'var(--muted-foreground)',
queued: 'var(--foreground)',
pending_status: 'var(--muted-foreground)',
successful: 'var(--primary)',
unsuccessful: 'var(--destructive)',
pending: 'var(--muted-foreground)',
auth: 'var(--primary)',
notification: 'var(--muted-foreground)',
workspace_invitation: 'var(--foreground)',
};
return (
colors[key] ??
['var(--primary)', 'var(--muted-foreground)', 'var(--foreground)'][
index % 3
]
);
}
function combineMailSeries(
key: string,
label: string,
series: { points: { bucket: string; count: number }[] }[]
) {
const first = series[0];
return {
key,
label,
total: series.reduce(
(total, item) =>
total + item.points.reduce((sum, point) => sum + point.count, 0),
0
),
points:
first?.points.map((point, index) => ({
bucket: point.bucket,
count: series.reduce(
(total, item) => total + (item.points[index]?.count ?? 0),
0
),
})) ?? [],
};
}
function MailDeliverySection({ hours }: { hours: number }) {
const [mode, setMode] = useState<MailChartMode>('status');
const { data } = useQuery(
{
query: adminMailDeliveriesQuery,
variables: {
input: { hours },
},
},
{
keepPreviousData: true,
revalidateOnFocus: false,
revalidateIfStale: true,
revalidateOnReconnect: true,
}
);
const analytics = data.adminMailDeliveries;
const sourceSeries =
mode === 'type'
? analytics.byType
: mode === 'outcome'
? analytics.byOutcome
: [
analytics.byStatus.find(series => series.key === 'sent'),
analytics.byStatus.find(series => series.key === 'failed'),
combineMailSeries(
'pending_status',
'Pending',
analytics.byStatus.filter(series =>
['queued', 'sending'].includes(series.key)
)
),
analytics.byStatus.find(series => series.key === 'retry_wait'),
].filter(series => series !== undefined);
const chartSeries = sourceSeries.map((series, index) => ({
key: series.key,
label: series.label,
total: series.total,
color: seriesColor(series.key, index),
}));
const chartPoints = toMailChartPoints(sourceSeries, hours);
const failedLike =
analytics.summary.failed +
analytics.summary.skipped +
analytics.summary.canceled;
const pending =
analytics.summary.queued +
analytics.summary.sending +
analytics.summary.retryWait;
return (
<Card className="border-border/60 bg-card shadow-1">
<CardHeader className="flex flex-col gap-3 pb-4 md:flex-row md:items-start md:justify-between">
<div className="space-y-1.5">
<CardTitle className="flex items-center gap-2 text-base">
<MailIcon className="h-4 w-4" aria-hidden="true" />
Email Delivery Trend
</CardTitle>
<CardDescription>
{mailWindowLabel(hours)} at{' '}
{analytics.window.bucket === 'Hour' ? 'hour' : 'day'} bucket in UTC
</CardDescription>
</div>
<Select
value={mode}
onValueChange={value => setMode(value as MailChartMode)}
>
<SelectTrigger className="w-full md:w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="status">Status</SelectItem>
<SelectItem value="type">Mail type</SelectItem>
<SelectItem value="outcome">Success / failure</SelectItem>
</SelectContent>
</Select>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<div className="rounded-md border border-border/60 bg-muted/15 px-3 py-2">
<div className="text-xs text-muted-foreground">Sent</div>
<div className="mt-1 text-xl font-semibold tabular-nums">
{compactFormatter.format(analytics.summary.sent)}
</div>
</div>
<div className="rounded-md border border-border/60 bg-muted/15 px-3 py-2">
<div className="text-xs text-muted-foreground">Not delivered</div>
<div className="mt-1 text-xl font-semibold tabular-nums">
{compactFormatter.format(failedLike)}
</div>
</div>
<div className="rounded-md border border-border/60 bg-muted/15 px-3 py-2">
<div className="text-xs text-muted-foreground">Pending</div>
<div className="mt-1 text-xl font-semibold tabular-nums">
{compactFormatter.format(pending)}
</div>
</div>
<div className="rounded-md border border-border/60 bg-muted/15 px-3 py-2">
<div className="text-xs text-muted-foreground">Success rate</div>
<div className="mt-1 text-xl font-semibold tabular-nums">
{(analytics.summary.successRate * 100).toFixed(1)}%
</div>
</div>
</div>
<MultiTrendChart
ariaLabel="Email delivery trend"
points={chartPoints}
series={chartSeries}
valueFormatter={value => intFormatter.format(value)}
/>
<div className="flex flex-wrap items-center gap-4 text-xs text-muted-foreground">
{chartSeries
.filter(series => series.total > 0)
.slice(0, 4)
.map(series => (
<div key={series.key} className="flex items-center gap-2">
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: series.color }}
aria-hidden="true"
/>
{series.label}: {compactFormatter.format(series.total)}
</div>
))}
</div>
<div className="flex justify-between text-xs text-muted-foreground tabular-nums">
<span>{mailBucketLabel(analytics.window.from, hours)}</span>
<span>{mailBucketLabel(analytics.window.to, hours)}</span>
</div>
</CardContent>
</Card>
);
}
function MailDeliveryCardSkeleton() {
return (
<Card className="border-border/60 bg-card shadow-1">
<CardHeader>
<Skeleton className="h-5 w-44" />
<Skeleton className="h-4 w-64" />
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
</div>
<Skeleton className="h-44 w-full" />
</CardContent>
</Card>
);
}
function DashboardPageContent() {
const [storageHistoryDays, setStorageHistoryDays] = useState<number>(30);
const [syncHistoryHours, setSyncHistoryHours] = useState<number>(48);
const [sharedLinkWindowDays, setSharedLinkWindowDays] = useState<number>(28);
const [mailDeliveryHours, setMailDeliveryHours] = useState<number>(24);
const shouldShowTopSharedLinks = !environment.isSelfHosted;
const revalidateQueryResource = useMutateQueryResource();
@@ -902,6 +1289,7 @@ function DashboardPageContent() {
isValidating={isValidating}
onRefresh={() => {
revalidateQueryResource(adminDashboardQuery).catch(() => {});
revalidateQueryResource(adminMailDeliveriesQuery).catch(() => {});
}}
/>
}
@@ -916,7 +1304,7 @@ function DashboardPageContent() {
automatically.
</CardDescription>
</CardHeader>
<CardContent className="grid grid-cols-1 items-end gap-3 md:grid-cols-2 lg:grid-cols-3">
<CardContent className="grid grid-cols-1 items-end gap-3 md:grid-cols-2 lg:grid-cols-4">
<WindowSelect
id="storage-history-window"
label="Storage History"
@@ -941,6 +1329,10 @@ function DashboardPageContent() {
unit="days"
onChange={setSharedLinkWindowDays}
/>
<MailWindowSelect
value={mailDeliveryHours}
onChange={setMailDeliveryHours}
/>
</CardContent>
</Card>
@@ -1035,6 +1427,10 @@ function DashboardPageContent() {
</Card>
</div>
<Suspense fallback={<MailDeliveryCardSkeleton />}>
<MailDeliverySection hours={mailDeliveryHours} />
</Suspense>
{shouldShowTopSharedLinks ? (
<Suspense fallback={<TopSharedLinksCardSkeleton />}>
<TopSharedLinksSection