Enhance warmup functionality by introducing mailbox role validation for sends and replies. Implement new utility functions to check allowed warmup pairs and add corresponding tests. Update campaign stats routes and services to include a detailed warmup plan, and integrate a visual representation in the Campaign Detail page. Refactor related components and types for improved clarity and maintainability.

This commit is contained in:
Jonny Nguyen 2026-06-26 13:56:57 -07:00
parent ac09760771
commit 9e895659db
13 changed files with 464 additions and 111 deletions

View file

@ -7,7 +7,7 @@
"dev": "tsx watch src/index.ts", "dev": "tsx watch src/index.ts",
"build": "tsc", "build": "tsc",
"start": "node dist/index.js", "start": "node dist/index.js",
"test": "tsx --test src/services/*.test.ts", "test": "tsx --test src/services/*.test.ts src/lib/*.test.ts",
"test:integration": "tsx --test --test-concurrency=1 src/test/integration/*.integration.test.ts" "test:integration": "tsx --test --test-concurrency=1 src/test/integration/*.integration.test.ts"
}, },
"dependencies": { "dependencies": {

View file

@ -2,6 +2,7 @@ import type { CampaignSatellite, Inbox } from '../generated/prisma/client';
import { CampaignStatus, InboxStatus, WarmupStatus, prisma } from '../lib/prisma'; import { CampaignStatus, InboxStatus, WarmupStatus, prisma } from '../lib/prisma';
import { withInboxGuard } from '../lib/inboxGuard'; import { withInboxGuard } from '../lib/inboxGuard';
import { logger } from '../lib/logger'; import { logger } from '../lib/logger';
import { isAllowedWarmupSendPair } from '../lib/mailbox-topology';
import { isWithinSendWindow } from '../lib/send-window'; import { isWithinSendWindow } from '../lib/send-window';
import * as aiService from '../services/ai.service'; import * as aiService from '../services/ai.service';
import { syncCampaignDay } from '../services/campaign.service'; import { syncCampaignDay } from '../services/campaign.service';
@ -84,6 +85,15 @@ async function executeCampaignSend(
return; return;
} }
if (!isAllowedWarmupSendPair(satellite, primary)) {
logger.warn(
{ campaignId, satellite: satellite.email, primary: primary.email },
'Campaign dispatcher skipped invalid warmup pair',
);
if (jobId) await completeSendJob(jobId, false, 'Invalid mailbox roles for warmup send');
return;
}
await withInboxGuard(satellite, async () => { await withInboxGuard(satellite, async () => {
const { subject, body, validationResult } = const { subject, body, validationResult } =
await aiService.generateInitialEmailWithValidation(primary.industry, { await aiService.generateInitialEmailWithValidation(primary.industry, {

View file

@ -1,7 +1,8 @@
import type { Inbox } from '../generated/prisma/client'; import type { Inbox } from '../generated/prisma/client';
import { InboxStatus, WarmupStatus, prisma } from '../lib/prisma'; import { InboxStatus, MailboxRole, WarmupStatus, prisma } from '../lib/prisma';
import { withInboxGuard } from '../lib/inboxGuard'; import { withInboxGuard } from '../lib/inboxGuard';
import { logger } from '../lib/logger'; import { logger } from '../lib/logger';
import { isAllowedWarmupSendPair, isPrimaryMailbox, isSatelliteMailbox } from '../lib/mailbox-topology';
import * as aiService from '../services/ai.service'; import * as aiService from '../services/ai.service';
import * as emailService from '../services/email.service'; import * as emailService from '../services/email.service';
import { getSettingOrDefault } from '../services/settings.service'; import { getSettingOrDefault } from '../services/settings.service';
@ -64,12 +65,18 @@ export async function runDispatcherJob(): Promise<void> {
where: { status: InboxStatus.active }, where: { status: InboxStatus.active },
}); });
if (inboxes.length < 2) { const satellites = inboxes.filter((inbox) => isSatelliteMailbox(inbox));
logger.warn('Dispatcher skipped: need at least 2 active inboxes'); const primaries = inboxes.filter((inbox) => isPrimaryMailbox(inbox));
if (satellites.length === 0 || primaries.length === 0) {
logger.warn(
{ satellites: satellites.length, primaries: primaries.length },
'Legacy dispatcher skipped: need at least one active satellite and one active primary',
);
return; return;
} }
for (const sender of inboxes) { for (const sender of satellites) {
try { try {
const sentToday = await getTodaySendCount(sender.id); const sentToday = await getTodaySendCount(sender.id);
if (sentToday >= sender.currentRamp) { if (sentToday >= sender.currentRamp) {
@ -77,8 +84,14 @@ export async function runDispatcherJob(): Promise<void> {
continue; continue;
} }
const receivers = inboxes.filter((i) => i.id !== sender.id); const receiver = pickRandom(primaries);
const receiver = pickRandom(receivers); if (!isAllowedWarmupSendPair(sender, receiver)) {
logger.warn(
{ sender: sender.email, receiver: receiver.email },
'Legacy dispatcher skipped invalid warmup pair',
);
continue;
}
await withInboxGuard(sender, async () => { await withInboxGuard(sender, async () => {
const { subject, body } = await aiService.generateInitialEmail(sender.industry, { const { subject, body } = await aiService.generateInitialEmail(sender.industry, {

View file

@ -1,10 +1,15 @@
import { simpleParser } from 'mailparser'; import { simpleParser } from 'mailparser';
import { ImapFlow } from 'imapflow'; import { ImapFlow } from 'imapflow';
import type { Inbox, WarmupCampaign, WarmupLog } from '../generated/prisma/client'; import type { Inbox, WarmupCampaign, WarmupLog } from '../generated/prisma/client';
import { CampaignStatus, InboxStatus, WarmupStatus } from '../lib/prisma'; import { CampaignStatus, InboxStatus, MailboxRole, WarmupStatus } from '../lib/prisma';
import { prisma } from '../lib/prisma'; import { prisma } from '../lib/prisma';
import { withInboxGuard } from '../lib/inboxGuard'; import { withInboxGuard } from '../lib/inboxGuard';
import { logger } from '../lib/logger'; import { logger } from '../lib/logger';
import {
isAllowedWarmupReplyPair,
isPrimaryMailbox,
isSatelliteMailbox,
} from '../lib/mailbox-topology';
import * as aiService from '../services/ai.service'; import * as aiService from '../services/ai.service';
import { hasAnyCampaign } from '../services/campaign.service'; import { hasAnyCampaign } from '../services/campaign.service';
import * as emailService from '../services/email.service'; import * as emailService from '../services/email.service';
@ -127,6 +132,20 @@ async function processMessage(
const log = await findMatchingLog(pendingLogs, messageId, fromEmail, subject); const log = await findMatchingLog(pendingLogs, messageId, fromEmail, subject);
if (!log) return; if (!log) return;
if (!isAllowedWarmupReplyPair(receiver, log.sender)) {
logger.warn(
{
receiver: receiver.email,
receiverRole: receiver.role,
sender: log.sender.email,
senderRole: log.sender.role,
logId: log.id,
},
'Skipping warmup reply: only primary may reply to satellite mail',
);
return;
}
const placement = options?.placement ?? 'inbox'; const placement = options?.placement ?? 'inbox';
await client.messageFlagsAdd(String(uid), ['\\Seen', '\\Flagged'], { uid: true }); await client.messageFlagsAdd(String(uid), ['\\Seen', '\\Flagged'], { uid: true });
@ -385,8 +404,9 @@ async function processCampaignPrimary(receiver: Inbox): Promise<void> {
include: { sender: true }, include: { sender: true },
})) as PendingLog[]; })) as PendingLog[];
const filtered = pendingLogs.filter((log) => const filtered = pendingLogs.filter(
allowedEmails.has(log.sender.email.toLowerCase()), (log) =>
allowedEmails.has(log.sender.email.toLowerCase()) && isSatelliteMailbox(log.sender),
); );
const campaignContext: CampaignContext = { const campaignContext: CampaignContext = {
@ -406,11 +426,16 @@ async function processCampaignPrimary(receiver: Inbox): Promise<void> {
} }
async function processLegacyInbox(receiver: Inbox): Promise<void> { async function processLegacyInbox(receiver: Inbox): Promise<void> {
if (!isPrimaryMailbox(receiver)) {
return;
}
const pendingLogs = (await prisma.warmupLog.findMany({ const pendingLogs = (await prisma.warmupLog.findMany({
where: { where: {
campaignId: null, campaignId: null,
receiverId: receiver.id, receiverId: receiver.id,
status: { in: [WarmupStatus.sent, WarmupStatus.rescued_from_spam] }, status: { in: [WarmupStatus.sent, WarmupStatus.rescued_from_spam] },
sender: { role: MailboxRole.satellite },
}, },
include: { sender: true }, include: { sender: true },
})) as PendingLog[]; })) as PendingLog[];
@ -442,11 +467,11 @@ export async function runRescuerJob(): Promise<void> {
} }
} }
} else { } else {
const inboxes = await prisma.inbox.findMany({ const primaries = await prisma.inbox.findMany({
where: { status: InboxStatus.active }, where: { status: InboxStatus.active, role: MailboxRole.primary },
}); });
for (const inbox of inboxes) { for (const inbox of primaries) {
try { try {
await processLegacyInbox(inbox); await processLegacyInbox(inbox);
} catch (err) { } catch (err) {

View file

@ -0,0 +1,64 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { MailboxRole } from './prisma';
import {
isAllowedWarmupReplyPair,
isAllowedWarmupSendPair,
} from './mailbox-topology';
describe('mailbox-topology', () => {
it('allows satellite → primary sends only', () => {
assert.equal(
isAllowedWarmupSendPair(
{ role: MailboxRole.satellite },
{ role: MailboxRole.primary },
),
true,
);
assert.equal(
isAllowedWarmupSendPair(
{ role: MailboxRole.satellite },
{ role: MailboxRole.satellite },
),
false,
);
assert.equal(
isAllowedWarmupSendPair(
{ role: MailboxRole.primary },
{ role: MailboxRole.primary },
),
false,
);
assert.equal(
isAllowedWarmupSendPair(
{ role: MailboxRole.unassigned },
{ role: MailboxRole.primary },
),
false,
);
});
it('allows primary → satellite replies only', () => {
assert.equal(
isAllowedWarmupReplyPair(
{ role: MailboxRole.primary },
{ role: MailboxRole.satellite },
),
true,
);
assert.equal(
isAllowedWarmupReplyPair(
{ role: MailboxRole.satellite },
{ role: MailboxRole.satellite },
),
false,
);
assert.equal(
isAllowedWarmupReplyPair(
{ role: MailboxRole.satellite },
{ role: MailboxRole.primary },
),
false,
);
});
});

View file

@ -0,0 +1,22 @@
import type { Inbox } from '../generated/prisma/client';
import { MailboxRole } from './prisma';
type RoleLike = Pick<Inbox, 'role'>;
export function isSatelliteMailbox(mailbox: RoleLike): boolean {
return mailbox.role === MailboxRole.satellite;
}
export function isPrimaryMailbox(mailbox: RoleLike): boolean {
return mailbox.role === MailboxRole.primary;
}
/** Warmup sends are satellite → primary only. */
export function isAllowedWarmupSendPair(sender: RoleLike, receiver: RoleLike): boolean {
return isSatelliteMailbox(sender) && isPrimaryMailbox(receiver);
}
/** Warmup replies are primary → satellite only. */
export function isAllowedWarmupReplyPair(responder: RoleLike, originalSender: RoleLike): boolean {
return isPrimaryMailbox(responder) && isSatelliteMailbox(originalSender);
}

View file

@ -5,6 +5,7 @@ import {
getCampaignStatsDaily, getCampaignStatsDaily,
getCampaignStatsSummary, getCampaignStatsSummary,
getCampaignStatsToday, getCampaignStatsToday,
getCampaignWarmupPlan,
getWorkspaceOverview, getWorkspaceOverview,
StatsRange, StatsRange,
} from '../services/stats.service'; } from '../services/stats.service';
@ -31,6 +32,15 @@ router.get('/:id/stats/today', async (req: Request, res: Response, next: NextFun
} }
}); });
router.get('/:id/stats/plan', async (req: Request, res: Response, next: NextFunction) => {
try {
const plan = await getCampaignWarmupPlan(req.params.id);
res.json(plan);
} catch (err) {
handleError(err, next);
}
});
router.get('/:id/stats/daily', async (req: Request, res: Response, next: NextFunction) => { router.get('/:id/stats/daily', async (req: Request, res: Response, next: NextFunction) => {
try { try {
const from = typeof req.query.from === 'string' ? req.query.from : undefined; const from = typeof req.query.from === 'string' ? req.query.from : undefined;

View file

@ -5,6 +5,27 @@ import { CampaignError } from './campaign.service';
export type StatsRange = '7d' | '30d' | 'all'; export type StatsRange = '7d' | '30d' | 'all';
export type CampaignWarmupPlanDay = {
dayIndex: number;
date: string | null;
isToday: boolean;
isFuture: boolean;
planned: { sends: number; replies: number };
actual: {
sends: number;
replies: number;
spamDetected: number;
spamRescued: number;
};
};
export type CampaignWarmupPlan = {
campaignId: string;
currentDay: number;
durationDays: number;
days: CampaignWarmupPlanDay[];
};
export type CampaignStatsPayload = { export type CampaignStatsPayload = {
campaignId: string; campaignId: string;
date: string; date: string;
@ -143,6 +164,47 @@ async function getCampaignOrThrow(campaignId: string) {
return campaign; return campaign;
} }
export async function getCampaignWarmupPlan(campaignId: string): Promise<CampaignWarmupPlan> {
const campaign = await getCampaignOrThrow(campaignId);
const zone = campaign.timezone;
const schedules = await prisma.dailySchedule.findMany({
where: { campaignId, dayIndex: { gt: 0 } },
orderBy: { dayIndex: 'asc' },
});
const days: CampaignWarmupPlanDay[] = schedules.map((schedule) => {
const isToday = schedule.dayIndex === campaign.currentDay;
const isFuture = campaign.currentDay > 0 && schedule.dayIndex > campaign.currentDay;
return {
dayIndex: schedule.dayIndex,
date: schedule.date
? (DateTime.fromJSDate(schedule.date).setZone(zone).toISODate() ?? null)
: null,
isToday,
isFuture,
planned: {
sends: schedule.plannedSends,
replies: schedule.plannedReplies,
},
actual: {
sends: schedule.actualSends,
replies: schedule.actualReplies,
spamDetected: schedule.spamDetected,
spamRescued: schedule.spamRescued,
},
};
});
return {
campaignId: campaign.id,
currentDay: campaign.currentDay,
durationDays: campaign.durationDays,
days,
};
}
export async function getCampaignStatsToday(campaignId: string): Promise<CampaignStatsPayload> { export async function getCampaignStatsToday(campaignId: string): Promise<CampaignStatsPayload> {
const campaign = await getCampaignOrThrow(campaignId); const campaign = await getCampaignOrThrow(campaignId);
const todayIso = DateTime.now().setZone(campaign.timezone).toISODate() ?? ''; const todayIso = DateTime.now().setZone(campaign.timezone).toISODate() ?? '';

View file

@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
import { CampaignRecipe, MailboxRole } from '../../lib/prisma'; import { CampaignRecipe, MailboxRole } from '../../lib/prisma';
import { assignSatellite, createCampaign, startCampaign } from '../../services/campaign.service'; import { assignSatellite, createCampaign, startCampaign } from '../../services/campaign.service';
import { prisma } from '../../lib/prisma'; import { prisma } from '../../lib/prisma';
import { getCampaignStatsToday } from '../../services/stats.service'; import { getCampaignStatsToday, getCampaignWarmupPlan } from '../../services/stats.service';
import { import {
cleanupCampaign, cleanupCampaign,
cleanupInbox, cleanupInbox,
@ -108,4 +108,45 @@ describe('integration: campaign stats flow', () => {
assert.equal(stats.actual.spamDetected, 1); assert.equal(stats.actual.spamDetected, 1);
assert.equal(stats.planned.sends, 40); assert.equal(stats.planned.sends, 40);
}); });
it('warmup plan returns full schedule with live actuals', async () => {
const primary = await createTestInbox({ role: MailboxRole.primary });
const sat1 = await createTestInbox();
const sat2 = await createTestInbox();
inboxIds.push(primary.id, sat1.id, sat2.id);
const campaign = await createCampaign({
primaryMailboxId: primary.id,
name: 'Plan test',
recipe: CampaignRecipe.grow,
durationDays: 14,
});
campaignIds.push(campaign.id);
await assignSatellite(campaign.id, sat1.id);
await assignSatellite(campaign.id, sat2.id);
const started = await startCampaign(campaign.id);
await prisma.dailySchedule.update({
where: {
campaignId_dayIndex: { campaignId: started.id, dayIndex: 1 },
},
data: {
actualSends: 3,
actualReplies: 1,
spamDetected: 1,
},
});
const plan = await getCampaignWarmupPlan(started.id);
assert.equal(plan.durationDays, 14);
assert.equal(plan.days.length, 14);
assert.equal(plan.days[0]!.dayIndex, 1);
assert.equal(plan.days[0]!.planned.sends, 1);
assert.equal(plan.days[0]!.actual.sends, 3);
assert.equal(plan.days[0]!.actual.replies, 1);
assert.equal(plan.days[0]!.actual.spamDetected, 1);
assert.equal(plan.days[0]!.isToday, true);
assert.equal(plan.days[1]!.isFuture, true);
assert.equal(plan.days[1]!.actual.sends, 0);
});
}); });

View file

@ -0,0 +1,153 @@
import {
Bar,
CartesianGrid,
ComposedChart,
Legend,
Line,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts';
import type { CampaignWarmupPlan, CampaignWarmupPlanDay } from '@warmbox/shared';
type ChartRow = {
dayIndex: number;
label: string;
date: string | null;
isToday: boolean;
isFuture: boolean;
plannedSends: number;
plannedReplies: number;
actualSends: number;
actualReplies: number;
spamDetected: number;
inboxDelivered: number;
};
function toChartRows(plan: CampaignWarmupPlan): ChartRow[] {
return plan.days.map((day) => ({
dayIndex: day.dayIndex,
label: `D${day.dayIndex}`,
date: day.date,
isToday: day.isToday,
isFuture: day.isFuture,
plannedSends: day.planned.sends,
plannedReplies: day.planned.replies,
actualSends: day.actual.sends,
actualReplies: day.actual.replies,
spamDetected: day.actual.spamDetected,
inboxDelivered: Math.max(0, day.actual.sends - day.actual.spamDetected),
}));
}
function PlanTooltip({
active,
payload,
}: {
active?: boolean;
payload?: Array<{ payload: ChartRow }>;
}) {
if (!active || !payload?.length) return null;
const row = payload[0]!.payload;
return (
<div className="rounded-lg border border-slate-200 bg-white p-3 shadow-lg text-sm min-w-[220px]">
<p className="font-semibold text-slate-900">
Day {row.dayIndex}
{row.date ? ` · ${row.date}` : ''}
</p>
{row.isToday && <p className="text-xs text-indigo-600 font-medium mt-0.5">Today</p>}
{row.isFuture && <p className="text-xs text-slate-400 mt-0.5">Upcoming</p>}
<dl className="mt-3 space-y-1.5">
<div className="flex justify-between gap-6">
<dt className="text-slate-500">Scheduled</dt>
<dd className="font-medium tabular-nums">
{row.plannedSends} sends · {row.plannedReplies} replies
</dd>
</div>
{!row.isFuture && (
<>
<div className="flex justify-between gap-6">
<dt className="text-slate-500">Sent</dt>
<dd className="font-medium tabular-nums text-indigo-600">{row.actualSends}</dd>
</div>
<div className="flex justify-between gap-6">
<dt className="text-slate-500">Replied</dt>
<dd className="font-medium tabular-nums text-emerald-600">{row.actualReplies}</dd>
</div>
<div className="flex justify-between gap-6">
<dt className="text-slate-500">In spam</dt>
<dd className="font-medium tabular-nums text-red-600">{row.spamDetected}</dd>
</div>
<div className="flex justify-between gap-6">
<dt className="text-slate-500">Inbox</dt>
<dd className="font-medium tabular-nums">{row.inboxDelivered}</dd>
</div>
</>
)}
</dl>
</div>
);
}
type WarmupPlanChartProps = {
plan: CampaignWarmupPlan;
};
export function WarmupPlanChart({ plan }: WarmupPlanChartProps) {
const data = toChartRows(plan);
const todayRow = data.find((row) => row.isToday);
if (data.length === 0) {
return (
<p className="text-sm text-slate-500 py-8 text-center">
Start the campaign to generate the warmup schedule.
</p>
);
}
return (
<div className="h-80">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={data} margin={{ top: 8, right: 8, left: 0, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" vertical={false} />
<XAxis
dataKey="label"
tick={{ fontSize: 11 }}
interval={plan.durationDays > 20 ? Math.floor(plan.durationDays / 10) : 0}
/>
<YAxis tick={{ fontSize: 12 }} allowDecimals={false} />
<Tooltip content={<PlanTooltip />} />
<Legend />
{todayRow && (
<ReferenceLine
x={todayRow.label}
stroke="#4f46e5"
strokeDasharray="4 4"
label={{ value: 'Today', position: 'top', fill: '#4f46e5', fontSize: 11 }}
/>
)}
<Line
type="monotone"
dataKey="plannedSends"
stroke="#94a3b8"
strokeWidth={2}
strokeDasharray="5 5"
dot={false}
name="Scheduled"
/>
<Bar dataKey="actualSends" fill="#4f46e5" name="Sent" radius={[2, 2, 0, 0]} />
<Bar dataKey="actualReplies" fill="#10b981" name="Replied" radius={[2, 2, 0, 0]} />
<Bar dataKey="spamDetected" fill="#ef4444" name="In spam" radius={[2, 2, 0, 0]} />
</ComposedChart>
</ResponsiveContainer>
</div>
);
}
export type { CampaignWarmupPlanDay };

View file

@ -2,6 +2,8 @@ import type {
CampaignRecipe, CampaignRecipe,
CampaignStatus, CampaignStatus,
CampaignStatsToday, CampaignStatsToday,
CampaignWarmupPlan,
CampaignWarmupPlanDay,
ComplianceReport, ComplianceReport,
DnsCheckResult, DnsCheckResult,
InboxStatus, InboxStatus,
@ -18,6 +20,8 @@ export type {
CampaignRecipe, CampaignRecipe,
CampaignStatus, CampaignStatus,
CampaignStatsToday, CampaignStatsToday,
CampaignWarmupPlan,
CampaignWarmupPlanDay,
ComplianceReport, ComplianceReport,
DnsCheckResult, DnsCheckResult,
InboxStatus, InboxStatus,
@ -91,16 +95,7 @@ export type Campaign = {
satellites: CampaignSatellite[]; satellites: CampaignSatellite[];
}; };
export type DailyStat = { export type DailyStat = CampaignStatsToday;
date: string;
dayIndex: number;
plannedSends: number;
plannedReplies: number;
actualSends: number;
actualReplies: number;
spamDetected: number;
spamRescued: number;
};
export type WarmupLog = { export type WarmupLog = {
id: string; id: string;

View file

@ -1,25 +1,14 @@
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { import { Send, MessageSquare, Shield, Inbox, Pause, Play, Loader2, Trash2 } from 'lucide-react';
LineChart,
Line,
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
} from 'recharts';
import { apiGet, apiPatch, apiPost, apiDelete } from '../lib/api'; import { apiGet, apiPatch, apiPost, apiDelete } from '../lib/api';
import type { import type {
Campaign, Campaign,
CampaignSatellite, CampaignSatellite,
CampaignStatsToday, CampaignStatsToday,
CampaignWarmupPlan,
ComplianceReport, ComplianceReport,
DailyStat,
PaginatedLogs, PaginatedLogs,
WarmupLog, WarmupLog,
} from '../lib/types'; } from '../lib/types';
@ -27,6 +16,7 @@ import { PageHeader } from '../components/PageHeader';
import { StatusBadge } from '../components/StatusBadge'; import { StatusBadge } from '../components/StatusBadge';
import { MetricCard } from '../components/MetricCard'; import { MetricCard } from '../components/MetricCard';
import { ComplianceChecklist } from '../components/ComplianceChecklist'; import { ComplianceChecklist } from '../components/ComplianceChecklist';
import { WarmupPlanChart } from '../components/WarmupPlanChart';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '../components/ui/Tabs'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '../components/ui/Tabs';
import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/Card'; import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/Card';
import { Skeleton } from '../components/ui/Skeleton'; import { Skeleton } from '../components/ui/Skeleton';
@ -36,7 +26,6 @@ import { Input } from '../components/ui/Input';
import { Label } from '../components/ui/Label'; import { Label } from '../components/ui/Label';
import { formatDateTime, formatPercent } from '../lib/utils'; import { formatDateTime, formatPercent } from '../lib/utils';
import { Badge } from '../components/ui/Badge'; import { Badge } from '../components/ui/Badge';
import { Send, MessageSquare, Shield, Inbox, Pause, Play, Loader2, Trash2 } from 'lucide-react';
function LogStatusBadge({ status }: { status: string }) { function LogStatusBadge({ status }: { status: string }) {
if (status === 'rescued_from_spam') { if (status === 'rescued_from_spam') {
@ -81,10 +70,11 @@ export function CampaignDetailPage() {
enabled: !!id, enabled: !!id,
}); });
const { data: dailyData } = useQuery({ const { data: warmupPlan } = useQuery({
queryKey: ['campaign', id, 'stats', 'daily'], queryKey: ['campaign', id, 'stats', 'plan'],
queryFn: () => apiGet<{ data: DailyStat[] }>(`/api/campaigns/${id}/stats/daily`), queryFn: () => apiGet<CampaignWarmupPlan>(`/api/campaigns/${id}/stats/plan`),
enabled: !!id, enabled: !!id,
refetchInterval: 30_000,
}); });
const { data: logs } = useQuery({ const { data: logs } = useQuery({
@ -142,6 +132,7 @@ export function CampaignDetailPage() {
mutationFn: () => apiPost<Campaign>(`/api/campaigns/${id}/pause`), mutationFn: () => apiPost<Campaign>(`/api/campaigns/${id}/pause`),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['campaign', id] }); queryClient.invalidateQueries({ queryKey: ['campaign', id] });
queryClient.invalidateQueries({ queryKey: ['campaign', id, 'stats'] });
queryClient.invalidateQueries({ queryKey: ['workspace', 'overview'] }); queryClient.invalidateQueries({ queryKey: ['workspace', 'overview'] });
}, },
}); });
@ -150,6 +141,7 @@ export function CampaignDetailPage() {
mutationFn: () => apiPost<Campaign>(`/api/campaigns/${id}/resume`), mutationFn: () => apiPost<Campaign>(`/api/campaigns/${id}/resume`),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['campaign', id] }); queryClient.invalidateQueries({ queryKey: ['campaign', id] });
queryClient.invalidateQueries({ queryKey: ['campaign', id, 'stats'] });
queryClient.invalidateQueries({ queryKey: ['workspace', 'overview'] }); queryClient.invalidateQueries({ queryKey: ['workspace', 'overview'] });
}, },
}); });
@ -205,16 +197,6 @@ export function CampaignDetailPage() {
!satellites?.some((s) => s.satelliteMailboxId === m.id), !satellites?.some((s) => s.satelliteMailboxId === m.id),
) ?? []; ) ?? [];
const chartData =
dailyData?.data.map((d) => ({
day: d.dayIndex,
sends: d.actualSends,
replies: d.actualReplies,
planned: d.plannedSends,
inbox: Math.max(0, d.actualSends - d.spamDetected),
spam: d.spamDetected,
})) ?? [];
if (isLoading) { if (isLoading) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@ -337,69 +319,23 @@ export function CampaignDetailPage() {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Daily Performance</CardTitle> <CardTitle>Warmup Plan</CardTitle>
<p className="text-sm text-slate-500 font-normal mt-1">
Full {campaign.durationDays}-day schedule with live progress. Hover a day for
scheduled vs sent, replied, and spam counts. Updates every 30 seconds.
</p>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{chartData.length === 0 ? ( {warmupPlan ? (
<p className="text-sm text-slate-500 py-8 text-center">No daily stats yet</p> <WarmupPlanChart plan={warmupPlan} />
) : campaign.currentDay === 0 ? (
<p className="text-sm text-slate-500 py-8 text-center">
Start the campaign to see the warmup plan chart.
</p>
) : ( ) : (
<div className="h-72"> <div className="flex items-center justify-center py-8 text-slate-500 gap-2">
<ResponsiveContainer width="100%" height="100%"> <Loader2 className="h-4 w-4 animate-spin" />
<LineChart data={chartData}> Loading warmup plan
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis dataKey="day" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} />
<Tooltip />
<Legend />
<Line
type="monotone"
dataKey="sends"
stroke="#4f46e5"
strokeWidth={2}
dot={false}
/>
<Line
type="monotone"
dataKey="replies"
stroke="#10b981"
strokeWidth={2}
dot={false}
/>
<Line
type="monotone"
dataKey="planned"
stroke="#94a3b8"
strokeWidth={1}
strokeDasharray="4 4"
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Inbox Placement (7d)</CardTitle>
</CardHeader>
<CardContent>
{chartData.length === 0 ? (
<p className="text-sm text-slate-500 py-8 text-center">No placement data yet</p>
) : (
<div className="h-72">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
<XAxis dataKey="day" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} />
<Tooltip />
<Legend />
<Bar dataKey="inbox" stackId="placement" fill="#10b981" name="Inbox" />
<Bar dataKey="spam" stackId="placement" fill="#ef4444" name="Spam" />
</BarChart>
</ResponsiveContainer>
</div> </div>
)} )}
</CardContent> </CardContent>

View file

@ -60,6 +60,28 @@ export type CampaignStatsToday = {
onTrackReasons: string[]; onTrackReasons: string[];
}; };
/** One day in the full-campaign warmup plan (scheduled vs live actuals). */
export type CampaignWarmupPlanDay = {
dayIndex: number;
date: string | null;
isToday: boolean;
isFuture: boolean;
planned: { sends: number; replies: number };
actual: {
sends: number;
replies: number;
spamDetected: number;
spamRescued: number;
};
};
export type CampaignWarmupPlan = {
campaignId: string;
currentDay: number;
durationDays: number;
days: CampaignWarmupPlanDay[];
};
export type WorkspaceOverview = { export type WorkspaceOverview = {
workspaceId: string | null; workspaceId: string | null;
count: number; count: number;