diff --git a/apps/api/package.json b/apps/api/package.json index 1dcf426..1cfde30 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -7,7 +7,7 @@ "dev": "tsx watch src/index.ts", "build": "tsc", "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" }, "dependencies": { diff --git a/apps/api/src/jobs/campaign-dispatcher.job.ts b/apps/api/src/jobs/campaign-dispatcher.job.ts index 48be468..4bbdee3 100644 --- a/apps/api/src/jobs/campaign-dispatcher.job.ts +++ b/apps/api/src/jobs/campaign-dispatcher.job.ts @@ -2,6 +2,7 @@ import type { CampaignSatellite, Inbox } from '../generated/prisma/client'; import { CampaignStatus, InboxStatus, WarmupStatus, prisma } from '../lib/prisma'; import { withInboxGuard } from '../lib/inboxGuard'; import { logger } from '../lib/logger'; +import { isAllowedWarmupSendPair } from '../lib/mailbox-topology'; import { isWithinSendWindow } from '../lib/send-window'; import * as aiService from '../services/ai.service'; import { syncCampaignDay } from '../services/campaign.service'; @@ -84,6 +85,15 @@ async function executeCampaignSend( 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 () => { const { subject, body, validationResult } = await aiService.generateInitialEmailWithValidation(primary.industry, { diff --git a/apps/api/src/jobs/dispatcher.job.ts b/apps/api/src/jobs/dispatcher.job.ts index 9004db8..3a67ef5 100644 --- a/apps/api/src/jobs/dispatcher.job.ts +++ b/apps/api/src/jobs/dispatcher.job.ts @@ -1,7 +1,8 @@ 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 { logger } from '../lib/logger'; +import { isAllowedWarmupSendPair, isPrimaryMailbox, isSatelliteMailbox } from '../lib/mailbox-topology'; import * as aiService from '../services/ai.service'; import * as emailService from '../services/email.service'; import { getSettingOrDefault } from '../services/settings.service'; @@ -64,12 +65,18 @@ export async function runDispatcherJob(): Promise { where: { status: InboxStatus.active }, }); - if (inboxes.length < 2) { - logger.warn('Dispatcher skipped: need at least 2 active inboxes'); + const satellites = inboxes.filter((inbox) => isSatelliteMailbox(inbox)); + 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; } - for (const sender of inboxes) { + for (const sender of satellites) { try { const sentToday = await getTodaySendCount(sender.id); if (sentToday >= sender.currentRamp) { @@ -77,8 +84,14 @@ export async function runDispatcherJob(): Promise { continue; } - const receivers = inboxes.filter((i) => i.id !== sender.id); - const receiver = pickRandom(receivers); + const receiver = pickRandom(primaries); + if (!isAllowedWarmupSendPair(sender, receiver)) { + logger.warn( + { sender: sender.email, receiver: receiver.email }, + 'Legacy dispatcher skipped invalid warmup pair', + ); + continue; + } await withInboxGuard(sender, async () => { const { subject, body } = await aiService.generateInitialEmail(sender.industry, { diff --git a/apps/api/src/jobs/rescuer.job.ts b/apps/api/src/jobs/rescuer.job.ts index 27bfcab..b3d4331 100644 --- a/apps/api/src/jobs/rescuer.job.ts +++ b/apps/api/src/jobs/rescuer.job.ts @@ -1,10 +1,15 @@ import { simpleParser } from 'mailparser'; import { ImapFlow } from 'imapflow'; 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 { withInboxGuard } from '../lib/inboxGuard'; import { logger } from '../lib/logger'; +import { + isAllowedWarmupReplyPair, + isPrimaryMailbox, + isSatelliteMailbox, +} from '../lib/mailbox-topology'; import * as aiService from '../services/ai.service'; import { hasAnyCampaign } from '../services/campaign.service'; import * as emailService from '../services/email.service'; @@ -127,6 +132,20 @@ async function processMessage( const log = await findMatchingLog(pendingLogs, messageId, fromEmail, subject); 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'; await client.messageFlagsAdd(String(uid), ['\\Seen', '\\Flagged'], { uid: true }); @@ -385,8 +404,9 @@ async function processCampaignPrimary(receiver: Inbox): Promise { include: { sender: true }, })) as PendingLog[]; - const filtered = pendingLogs.filter((log) => - allowedEmails.has(log.sender.email.toLowerCase()), + const filtered = pendingLogs.filter( + (log) => + allowedEmails.has(log.sender.email.toLowerCase()) && isSatelliteMailbox(log.sender), ); const campaignContext: CampaignContext = { @@ -406,11 +426,16 @@ async function processCampaignPrimary(receiver: Inbox): Promise { } async function processLegacyInbox(receiver: Inbox): Promise { + if (!isPrimaryMailbox(receiver)) { + return; + } + const pendingLogs = (await prisma.warmupLog.findMany({ where: { campaignId: null, receiverId: receiver.id, status: { in: [WarmupStatus.sent, WarmupStatus.rescued_from_spam] }, + sender: { role: MailboxRole.satellite }, }, include: { sender: true }, })) as PendingLog[]; @@ -442,11 +467,11 @@ export async function runRescuerJob(): Promise { } } } else { - const inboxes = await prisma.inbox.findMany({ - where: { status: InboxStatus.active }, + const primaries = await prisma.inbox.findMany({ + where: { status: InboxStatus.active, role: MailboxRole.primary }, }); - for (const inbox of inboxes) { + for (const inbox of primaries) { try { await processLegacyInbox(inbox); } catch (err) { diff --git a/apps/api/src/lib/mailbox-topology.test.ts b/apps/api/src/lib/mailbox-topology.test.ts new file mode 100644 index 0000000..6b05eb5 --- /dev/null +++ b/apps/api/src/lib/mailbox-topology.test.ts @@ -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, + ); + }); +}); diff --git a/apps/api/src/lib/mailbox-topology.ts b/apps/api/src/lib/mailbox-topology.ts new file mode 100644 index 0000000..ef5dfbf --- /dev/null +++ b/apps/api/src/lib/mailbox-topology.ts @@ -0,0 +1,22 @@ +import type { Inbox } from '../generated/prisma/client'; +import { MailboxRole } from './prisma'; + +type RoleLike = Pick; + +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); +} diff --git a/apps/api/src/routes/stats.routes.ts b/apps/api/src/routes/stats.routes.ts index edbc531..f31c681 100644 --- a/apps/api/src/routes/stats.routes.ts +++ b/apps/api/src/routes/stats.routes.ts @@ -5,6 +5,7 @@ import { getCampaignStatsDaily, getCampaignStatsSummary, getCampaignStatsToday, + getCampaignWarmupPlan, getWorkspaceOverview, StatsRange, } 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) => { try { const from = typeof req.query.from === 'string' ? req.query.from : undefined; diff --git a/apps/api/src/services/stats.service.ts b/apps/api/src/services/stats.service.ts index f0cf8a3..be358e9 100644 --- a/apps/api/src/services/stats.service.ts +++ b/apps/api/src/services/stats.service.ts @@ -5,6 +5,27 @@ import { CampaignError } from './campaign.service'; 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 = { campaignId: string; date: string; @@ -143,6 +164,47 @@ async function getCampaignOrThrow(campaignId: string) { return campaign; } +export async function getCampaignWarmupPlan(campaignId: string): Promise { + 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 { const campaign = await getCampaignOrThrow(campaignId); const todayIso = DateTime.now().setZone(campaign.timezone).toISODate() ?? ''; diff --git a/apps/api/src/test/integration/concurrent-primaries.integration.test.ts b/apps/api/src/test/integration/concurrent-primaries.integration.test.ts index f9f0403..375f4b8 100644 --- a/apps/api/src/test/integration/concurrent-primaries.integration.test.ts +++ b/apps/api/src/test/integration/concurrent-primaries.integration.test.ts @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import { CampaignRecipe, MailboxRole } from '../../lib/prisma'; import { assignSatellite, createCampaign, startCampaign } from '../../services/campaign.service'; import { prisma } from '../../lib/prisma'; -import { getCampaignStatsToday } from '../../services/stats.service'; +import { getCampaignStatsToday, getCampaignWarmupPlan } from '../../services/stats.service'; import { cleanupCampaign, cleanupInbox, @@ -108,4 +108,45 @@ describe('integration: campaign stats flow', () => { assert.equal(stats.actual.spamDetected, 1); 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); + }); }); diff --git a/apps/web/src/components/WarmupPlanChart.tsx b/apps/web/src/components/WarmupPlanChart.tsx new file mode 100644 index 0000000..21e0b55 --- /dev/null +++ b/apps/web/src/components/WarmupPlanChart.tsx @@ -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 ( +
+

+ Day {row.dayIndex} + {row.date ? ` · ${row.date}` : ''} +

+ {row.isToday &&

Today

} + {row.isFuture &&

Upcoming

} + +
+
+
Scheduled
+
+ {row.plannedSends} sends · {row.plannedReplies} replies +
+
+ + {!row.isFuture && ( + <> +
+
Sent
+
{row.actualSends}
+
+
+
Replied
+
{row.actualReplies}
+
+
+
In spam
+
{row.spamDetected}
+
+
+
Inbox
+
{row.inboxDelivered}
+
+ + )} +
+
+ ); +} + +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 ( +

+ Start the campaign to generate the warmup schedule. +

+ ); + } + + return ( +
+ + + + 20 ? Math.floor(plan.durationDays / 10) : 0} + /> + + } /> + + {todayRow && ( + + )} + + + + + + +
+ ); +} + +export type { CampaignWarmupPlanDay }; diff --git a/apps/web/src/lib/types.ts b/apps/web/src/lib/types.ts index d4ddb27..6663934 100644 --- a/apps/web/src/lib/types.ts +++ b/apps/web/src/lib/types.ts @@ -2,6 +2,8 @@ import type { CampaignRecipe, CampaignStatus, CampaignStatsToday, + CampaignWarmupPlan, + CampaignWarmupPlanDay, ComplianceReport, DnsCheckResult, InboxStatus, @@ -18,6 +20,8 @@ export type { CampaignRecipe, CampaignStatus, CampaignStatsToday, + CampaignWarmupPlan, + CampaignWarmupPlanDay, ComplianceReport, DnsCheckResult, InboxStatus, @@ -91,16 +95,7 @@ export type Campaign = { satellites: CampaignSatellite[]; }; -export type DailyStat = { - date: string; - dayIndex: number; - plannedSends: number; - plannedReplies: number; - actualSends: number; - actualReplies: number; - spamDetected: number; - spamRescued: number; -}; +export type DailyStat = CampaignStatsToday; export type WarmupLog = { id: string; diff --git a/apps/web/src/pages/CampaignDetailPage.tsx b/apps/web/src/pages/CampaignDetailPage.tsx index da20d18..d8f46ff 100644 --- a/apps/web/src/pages/CampaignDetailPage.tsx +++ b/apps/web/src/pages/CampaignDetailPage.tsx @@ -1,25 +1,14 @@ import { useParams, useNavigate } from 'react-router-dom'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useEffect, useState } from 'react'; -import { - LineChart, - Line, - BarChart, - Bar, - XAxis, - YAxis, - CartesianGrid, - Tooltip, - ResponsiveContainer, - Legend, -} from 'recharts'; +import { Send, MessageSquare, Shield, Inbox, Pause, Play, Loader2, Trash2 } from 'lucide-react'; import { apiGet, apiPatch, apiPost, apiDelete } from '../lib/api'; import type { Campaign, CampaignSatellite, CampaignStatsToday, + CampaignWarmupPlan, ComplianceReport, - DailyStat, PaginatedLogs, WarmupLog, } from '../lib/types'; @@ -27,6 +16,7 @@ import { PageHeader } from '../components/PageHeader'; import { StatusBadge } from '../components/StatusBadge'; import { MetricCard } from '../components/MetricCard'; import { ComplianceChecklist } from '../components/ComplianceChecklist'; +import { WarmupPlanChart } from '../components/WarmupPlanChart'; import { Tabs, TabsList, TabsTrigger, TabsContent } from '../components/ui/Tabs'; import { Card, CardContent, CardHeader, CardTitle } from '../components/ui/Card'; import { Skeleton } from '../components/ui/Skeleton'; @@ -36,7 +26,6 @@ import { Input } from '../components/ui/Input'; import { Label } from '../components/ui/Label'; import { formatDateTime, formatPercent } from '../lib/utils'; import { Badge } from '../components/ui/Badge'; -import { Send, MessageSquare, Shield, Inbox, Pause, Play, Loader2, Trash2 } from 'lucide-react'; function LogStatusBadge({ status }: { status: string }) { if (status === 'rescued_from_spam') { @@ -81,10 +70,11 @@ export function CampaignDetailPage() { enabled: !!id, }); - const { data: dailyData } = useQuery({ - queryKey: ['campaign', id, 'stats', 'daily'], - queryFn: () => apiGet<{ data: DailyStat[] }>(`/api/campaigns/${id}/stats/daily`), + const { data: warmupPlan } = useQuery({ + queryKey: ['campaign', id, 'stats', 'plan'], + queryFn: () => apiGet(`/api/campaigns/${id}/stats/plan`), enabled: !!id, + refetchInterval: 30_000, }); const { data: logs } = useQuery({ @@ -142,6 +132,7 @@ export function CampaignDetailPage() { mutationFn: () => apiPost(`/api/campaigns/${id}/pause`), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['campaign', id] }); + queryClient.invalidateQueries({ queryKey: ['campaign', id, 'stats'] }); queryClient.invalidateQueries({ queryKey: ['workspace', 'overview'] }); }, }); @@ -150,6 +141,7 @@ export function CampaignDetailPage() { mutationFn: () => apiPost(`/api/campaigns/${id}/resume`), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['campaign', id] }); + queryClient.invalidateQueries({ queryKey: ['campaign', id, 'stats'] }); queryClient.invalidateQueries({ queryKey: ['workspace', 'overview'] }); }, }); @@ -205,16 +197,6 @@ export function CampaignDetailPage() { !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) { return (
@@ -337,69 +319,23 @@ export function CampaignDetailPage() { - Daily Performance + Warmup Plan +

+ Full {campaign.durationDays}-day schedule with live progress. Hover a day for + scheduled vs sent, replied, and spam counts. Updates every 30 seconds. +

- {chartData.length === 0 ? ( -

No daily stats yet

+ {warmupPlan ? ( + + ) : campaign.currentDay === 0 ? ( +

+ Start the campaign to see the warmup plan chart. +

) : ( -
- - - - - - - - - - - - -
- )} -
-
- - - - Inbox Placement (7d) - - - {chartData.length === 0 ? ( -

No placement data yet

- ) : ( -
- - - - - - - - - - - +
+ + Loading warmup plan…
)} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index d68d77b..ca496f3 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -60,6 +60,28 @@ export type CampaignStatsToday = { 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 = { workspaceId: string | null; count: number;