230 lines
6.4 KiB
TypeScript
230 lines
6.4 KiB
TypeScript
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';
|
|
import * as emailService from '../services/email.service';
|
|
import {
|
|
claimDueSendJob,
|
|
completeSendJob,
|
|
enqueueSendJob,
|
|
} from '../services/job-queue.service';
|
|
|
|
const MIN_JITTER_MS = 45 * 60 * 1000;
|
|
|
|
type SatelliteWithMailbox = CampaignSatellite & { satelliteMailbox: Inbox };
|
|
|
|
function pickWeightedSatellite(satellites: SatelliteWithMailbox[]): Inbox {
|
|
const totalWeight = satellites.reduce((sum, sat) => sum + sat.weight, 0);
|
|
let remaining = Math.random() * totalWeight;
|
|
|
|
for (const satellite of satellites) {
|
|
remaining -= satellite.weight;
|
|
if (remaining <= 0) {
|
|
return satellite.satelliteMailbox;
|
|
}
|
|
}
|
|
|
|
return satellites[satellites.length - 1]!.satelliteMailbox;
|
|
}
|
|
|
|
function hasElapsedMinJitter(lastSendAt: Date | null): boolean {
|
|
if (!lastSendAt) return true;
|
|
return Date.now() - lastSendAt.getTime() >= MIN_JITTER_MS;
|
|
}
|
|
|
|
async function executeCampaignSend(
|
|
campaignId: string,
|
|
jobId: string | null,
|
|
): Promise<void> {
|
|
const campaign = await prisma.warmupCampaign.findUnique({
|
|
where: { id: campaignId },
|
|
include: {
|
|
primaryMailbox: true,
|
|
satellites: {
|
|
where: { active: true },
|
|
include: { satelliteMailbox: true },
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!campaign) return;
|
|
|
|
const activeSatellites = campaign.satellites.filter(
|
|
(sat) => sat.satelliteMailbox.status === InboxStatus.active,
|
|
);
|
|
if (activeSatellites.length === 0) {
|
|
logger.warn({ campaignId }, 'Campaign has no active satellites');
|
|
if (jobId) await completeSendJob(jobId, false, 'No active satellites');
|
|
return;
|
|
}
|
|
|
|
const schedule = await prisma.dailySchedule.findUnique({
|
|
where: {
|
|
campaignId_dayIndex: {
|
|
campaignId: campaign.id,
|
|
dayIndex: campaign.currentDay,
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!schedule || schedule.actualSends >= schedule.plannedSends) {
|
|
if (jobId) await completeSendJob(jobId, true, 'Daily send quota reached');
|
|
return;
|
|
}
|
|
|
|
const satellite = pickWeightedSatellite(activeSatellites);
|
|
const primary = campaign.primaryMailbox;
|
|
|
|
if (primary.status !== InboxStatus.active) {
|
|
logger.warn({ campaignId }, 'Primary mailbox is not active');
|
|
if (jobId) await completeSendJob(jobId, false, 'Primary mailbox inactive');
|
|
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, {
|
|
senderName: satellite.senderName,
|
|
companyName: satellite.companyName,
|
|
});
|
|
|
|
const { messageId } = await emailService.sendMail(satellite, {
|
|
to: primary.email,
|
|
subject,
|
|
body,
|
|
});
|
|
|
|
await prisma.$transaction(async (tx) => {
|
|
const current = await tx.dailySchedule.findUnique({
|
|
where: { id: schedule.id },
|
|
});
|
|
if (!current || current.actualSends >= current.plannedSends) {
|
|
return;
|
|
}
|
|
|
|
await tx.warmupLog.create({
|
|
data: {
|
|
campaignId: campaign.id,
|
|
senderId: satellite.id,
|
|
receiverId: primary.id,
|
|
messageId,
|
|
subject,
|
|
body,
|
|
status: WarmupStatus.sent,
|
|
validationResult: JSON.stringify(validationResult),
|
|
},
|
|
});
|
|
|
|
await tx.dailySchedule.update({
|
|
where: { id: schedule.id },
|
|
data: { actualSends: { increment: 1 } },
|
|
});
|
|
|
|
await tx.warmupCampaign.update({
|
|
where: { id: campaign.id },
|
|
data: { lastSendAt: new Date() },
|
|
});
|
|
});
|
|
|
|
if (jobId) await completeSendJob(jobId, true);
|
|
|
|
logger.info(
|
|
{
|
|
campaignId: campaign.id,
|
|
satellite: satellite.email,
|
|
primary: primary.email,
|
|
messageId,
|
|
},
|
|
'Campaign warmup email dispatched',
|
|
);
|
|
});
|
|
}
|
|
|
|
export async function runCampaignDispatcherJob(): Promise<string> {
|
|
logger.info('Campaign dispatcher job started');
|
|
|
|
const campaigns = await prisma.warmupCampaign.findMany({
|
|
where: {
|
|
status: { in: [CampaignStatus.active, CampaignStatus.maintenance] },
|
|
},
|
|
include: {
|
|
primaryMailbox: true,
|
|
satellites: {
|
|
where: { active: true },
|
|
include: { satelliteMailbox: true },
|
|
},
|
|
},
|
|
});
|
|
|
|
if (campaigns.length === 0) {
|
|
logger.info('Campaign dispatcher skipped: no active campaigns');
|
|
return 'No active campaigns';
|
|
}
|
|
|
|
let processed = 0;
|
|
|
|
for (const campaign of campaigns) {
|
|
try {
|
|
await syncCampaignDay(campaign.id);
|
|
|
|
const refreshed = await prisma.warmupCampaign.findUnique({
|
|
where: { id: campaign.id },
|
|
});
|
|
if (!refreshed) continue;
|
|
|
|
if (!hasElapsedMinJitter(refreshed.lastSendAt)) {
|
|
logger.debug({ campaignId: campaign.id }, 'Campaign within jitter window');
|
|
continue;
|
|
}
|
|
|
|
if (
|
|
!isWithinSendWindow(
|
|
refreshed.timezone,
|
|
refreshed.sendWindowStart,
|
|
refreshed.sendWindowEnd,
|
|
)
|
|
) {
|
|
logger.debug({ campaignId: campaign.id }, 'Campaign outside send window');
|
|
continue;
|
|
}
|
|
|
|
const schedule = await prisma.dailySchedule.findUnique({
|
|
where: {
|
|
campaignId_dayIndex: {
|
|
campaignId: campaign.id,
|
|
dayIndex: refreshed.currentDay,
|
|
},
|
|
},
|
|
});
|
|
|
|
if (!schedule || schedule.actualSends >= schedule.plannedSends) {
|
|
continue;
|
|
}
|
|
|
|
await enqueueSendJob(campaign.id, new Date());
|
|
const job = await claimDueSendJob(campaign.id);
|
|
if (!job) continue;
|
|
|
|
await executeCampaignSend(campaign.id, job.id);
|
|
processed += 1;
|
|
} catch (err) {
|
|
logger.error({ err, campaignId: campaign.id }, 'Campaign dispatcher failed');
|
|
}
|
|
}
|
|
|
|
logger.info('Campaign dispatcher job finished');
|
|
return `Processed ${processed} send job(s)`;
|
|
}
|