mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-18 10:36:22 +08:00
feat(server): refactor mail queue (#15204)
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user