import type {Campaign, Contact, Prisma} from '@plunk/db'; import {CampaignAudienceType, CampaignStatus} from '@plunk/db'; import type {FilterCondition} from '@plunk/types'; import {prisma} from '../database/prisma.js'; import {HttpException} from '../exceptions/index.js'; import {buildEmailFieldsUpdate} from '../utils/modelUpdate.js'; import {DomainService} from './DomainService.js'; import {EmailService} from './EmailService.js'; import {NtfyService} from './NtfyService.js'; import {QueueService} from './QueueService.js'; import {SegmentService} from './SegmentService.js'; import {DASHBOARD_URI} from '../app/constants.js'; import {sendRawEmail} from './SESService.js'; const BATCH_SIZE = 500; // Number of emails to process per batch (increased for better performance) export interface CreateCampaignData { name: string; description?: string; subject: string; body: string; from: string; fromName?: string; replyTo?: string; audienceType: CampaignAudienceType; audienceCondition?: FilterCondition; segmentId?: string; } export interface UpdateCampaignData { name?: string; description?: string; subject?: string; body?: string; from?: string; fromName?: string; replyTo?: string; audienceType?: CampaignAudienceType; audienceCondition?: FilterCondition; segmentId?: string; } export class CampaignService { /** * Create a new campaign */ public static async create(projectId: string, data: CreateCampaignData): Promise { // Validate segment if provided if (data.audienceType === CampaignAudienceType.SEGMENT) { if (!data.segmentId) { throw new HttpException(400, 'Segment ID is required for SEGMENT audience type'); } const segment = await prisma.segment.findFirst({ where: { id: data.segmentId, projectId, }, }); if (!segment) { throw new HttpException(404, 'Segment not found'); } } // Validate condition if provided if (data.audienceType === CampaignAudienceType.FILTERED && data.audienceCondition) { // This will throw if condition is invalid SegmentService.validateCondition(data.audienceCondition); } // Create campaign const campaign = await prisma.campaign.create({ data: { projectId, name: data.name, description: data.description, subject: data.subject, body: data.body, from: data.from, fromName: data.fromName, replyTo: data.replyTo, audienceType: data.audienceType, audienceCondition: (data.audienceCondition || null) as unknown as Prisma.InputJsonValue, segmentId: data.segmentId, status: CampaignStatus.DRAFT, }, include: { project: { select: {name: true}, }, }, }); // Send notification about campaign creation await NtfyService.notifyCampaignCreated(campaign.name, campaign.project.name, projectId); return campaign; } /** * Update a campaign */ public static async update(projectId: string, campaignId: string, data: UpdateCampaignData): Promise { const campaign = await this.get(projectId, campaignId); // Can only update draft or scheduled campaigns if (campaign.status !== CampaignStatus.DRAFT && campaign.status !== CampaignStatus.SCHEDULED) { throw new HttpException(400, 'Cannot update campaign that is sending or has been sent'); } // Build base update data using shared utility const updateData: Prisma.CampaignUpdateInput = buildEmailFieldsUpdate(data) as Prisma.CampaignUpdateInput; // Handle campaign-specific fields if (data.audienceType !== undefined) { updateData.audienceType = data.audienceType; } if (data.audienceCondition !== undefined) { if (data.audienceCondition) { SegmentService.validateCondition(data.audienceCondition); } updateData.audienceCondition = (data.audienceCondition || null) as unknown as Prisma.InputJsonValue; } if (data.segmentId !== undefined) { // Prevent changing segment on scheduled campaigns if (campaign.status === CampaignStatus.SCHEDULED) { throw new HttpException(400, 'Cannot change segment for scheduled campaigns'); } if (data.segmentId) { const segment = await prisma.segment.findFirst({ where: {id: data.segmentId, projectId}, }); if (!segment) { throw new HttpException(404, 'Segment not found'); } updateData.segment = {connect: {id: data.segmentId}}; } else { updateData.segment = {disconnect: true}; } // Remove segmentId from updateData to avoid conflict with relation field delete (updateData as Record).segmentId; } return prisma.campaign.update({ where: {id: campaignId}, data: updateData, }); } /** * Get a campaign */ public static async get(projectId: string, campaignId: string): Promise { const campaign = await prisma.campaign.findFirst({ where: { id: campaignId, projectId, }, include: { segment: true, }, }); if (!campaign) { throw new HttpException(404, 'Campaign not found'); } return campaign; } /** * List campaigns for a project */ public static async list( projectId: string, options: { status?: CampaignStatus; page?: number; pageSize?: number; } = {}, ): Promise<{campaigns: Campaign[]; total: number; page: number; pageSize: number; totalPages: number}> { const {status, page = 1, pageSize = 20} = options; const skip = (page - 1) * pageSize; const where: Prisma.CampaignWhereInput = { projectId, ...(status ? {status} : {}), }; const [campaigns, total] = await Promise.all([ prisma.campaign.findMany({ where, include: { segment: true, }, orderBy: {createdAt: 'desc'}, skip, take: pageSize, }), prisma.campaign.count({where}), ]); return { campaigns, total, page, pageSize, totalPages: Math.ceil(total / pageSize), }; } /** * Delete a campaign */ public static async delete(projectId: string, campaignId: string): Promise { const campaign = await prisma.campaign.findUnique({ where: {id: campaignId, projectId}, include: { project: { select: {name: true}, }, }, }); if (!campaign) { throw new HttpException(404, 'Campaign not found'); } // Can only delete draft campaigns if (campaign.status !== CampaignStatus.DRAFT) { throw new HttpException(400, 'Can only delete draft campaigns'); } await prisma.campaign.delete({ where: {id: campaignId}, }); // Send notification about campaign deletion await NtfyService.notifyCampaignDeleted(campaign.name, campaign.project.name, projectId); } /** * Duplicate a campaign */ public static async duplicate(projectId: string, campaignId: string): Promise { const campaign = await this.get(projectId, campaignId); // Create a new campaign with the same data but reset status and stats return prisma.campaign.create({ data: { projectId, name: `${campaign.name} (Copy)`, description: campaign.description, subject: campaign.subject, body: campaign.body, from: campaign.from, fromName: campaign.fromName, replyTo: campaign.replyTo, audienceType: campaign.audienceType, audienceCondition: campaign.audienceCondition as Prisma.InputJsonValue, segmentId: campaign.segmentId, status: CampaignStatus.DRAFT, totalRecipients: 0, sentCount: 0, deliveredCount: 0, openedCount: 0, clickedCount: 0, bouncedCount: 0, }, }); } /** * Send campaign immediately or schedule for later */ public static async send(projectId: string, campaignId: string, scheduledFor?: Date): Promise { const campaign = await this.get(projectId, campaignId); // Validate status if (campaign.status !== CampaignStatus.DRAFT && campaign.status !== CampaignStatus.SCHEDULED) { throw new HttpException(400, 'Campaign has already been sent or is currently sending'); } // Get recipient count to validate there are contacts to send to const recipientCount = await this.getRecipientCount(projectId, campaign); if (recipientCount === 0) { throw new HttpException(400, 'Campaign has no recipients'); } if (scheduledFor) { // Schedule for later if (scheduledFor.getTime() <= Date.now()) { throw new HttpException(400, 'Scheduled time must be in the future'); } // Update campaign status const updatedCampaign = await prisma.campaign.update({ where: {id: campaignId}, data: { status: CampaignStatus.SCHEDULED, scheduledFor, totalRecipients: recipientCount, }, include: { project: { select: {name: true}, }, }, }); // Queue for scheduled sending await QueueService.scheduleCampaign(campaignId, scheduledFor); // Send notification about campaign scheduled await NtfyService.notifyCampaignScheduled( updatedCampaign.name, updatedCampaign.project.name, projectId, scheduledFor, recipientCount, ); return updatedCampaign; } else { // Send immediately - start the batch processing await this.startSending(projectId, campaignId, recipientCount); return this.get(projectId, campaignId); } } /** * Start sending campaign (called immediately or when scheduled time arrives) * Now uses cursor-based pagination for better performance with large recipient lists */ public static async startSending(projectId: string, campaignId: string, recipientCount?: number): Promise { const campaign = await this.get(projectId, campaignId); // Validate status if ( campaign.status !== CampaignStatus.DRAFT && campaign.status !== CampaignStatus.SCHEDULED && campaign.status !== CampaignStatus.SENDING ) { throw new HttpException(400, 'Campaign cannot be sent in its current status'); } // Get recipient count if not provided if (recipientCount === undefined) { recipientCount = await this.getRecipientCount(projectId, campaign); } // Update campaign to SENDING status const updatedCampaign = await prisma.campaign.update({ where: {id: campaignId}, data: { status: CampaignStatus.SENDING, totalRecipients: recipientCount, sentAt: new Date(), }, include: { project: { select: {name: true}, }, }, }); // Send notification about campaign sending started await NtfyService.notifyCampaignSendingStarted( updatedCampaign.name, updatedCampaign.project.name, projectId, recipientCount, ); // Queue first batch to start the cursor-based chain await QueueService.queueCampaignBatch({ campaignId, batchNumber: 1, offset: 0, limit: BATCH_SIZE, }); } /** * Process a single batch of campaign emails * Now uses cursor-based pagination for better performance */ public static async processBatch( campaignId: string, batchNumber: number, offset: number, limit: number, cursor?: string, ): Promise { const campaign = await prisma.campaign.findUnique({ where: {id: campaignId}, include: { project: true, }, }); if (!campaign) { throw new HttpException(404, 'Campaign not found'); } if (campaign.status !== CampaignStatus.SENDING) { console.warn(`[CAMPAIGN] Campaign ${campaignId} is not in SENDING status, skipping batch ${batchNumber}`); return; } // Get batch of recipients using cursor-based pagination const {contacts, nextCursor, hasMore} = await this.getRecipientsCursor(campaign.projectId, campaign, limit, cursor); // Queue emails for each contact for (const contact of contacts) { try { // Render template with contact data const contactData = contact.data && typeof contact.data === 'object' && !Array.isArray(contact.data) ? contact.data : {}; const variables = { email: contact.email, ...contactData, data: contactData, unsubscribeUrl: `${DASHBOARD_URI}/unsubscribe/${contact.id}`, subscribeUrl: `${DASHBOARD_URI}/subscribe/${contact.id}`, manageUrl: `${DASHBOARD_URI}/manage/${contact.id}`, }; const renderedSubject = EmailService.format({ subject: campaign.subject, body: '', data: variables, }).subject; const renderedBody = EmailService.format({ subject: '', body: campaign.body, data: variables, }).body; await EmailService.sendCampaignEmail({ projectId: campaign.projectId, contactId: contact.id, campaignId: campaign.id, templateId: undefined, subject: renderedSubject, body: renderedBody, from: campaign.from, fromName: campaign.fromName || undefined, replyTo: campaign.replyTo || undefined, }); } catch (error) { console.error(`[CAMPAIGN] Failed to queue email for contact ${contact.id}:`, error); // Continue with other contacts even if one fails } } // Update sent count await prisma.campaign.update({ where: {id: campaignId}, data: { sentCount: { increment: contacts.length, }, }, }); // Queue next batch if there are more contacts if (hasMore && nextCursor) { await QueueService.queueCampaignBatch({ campaignId, batchNumber: batchNumber + 1, offset: 0, // Not used with cursor pagination limit, cursor: nextCursor, }); } else { // All batches processed, mark campaign as SENT const completedCampaign = await prisma.campaign.update({ where: {id: campaignId}, data: { status: CampaignStatus.SENT, }, include: { project: { select: {name: true}, }, }, }); // Send notification about campaign send completed await NtfyService.notifyCampaignSendCompleted( completedCampaign.name, completedCampaign.project.name, completedCampaign.projectId, completedCampaign.totalRecipients || 0, ); } } /** * Cancel a campaign */ public static async cancel(projectId: string, campaignId: string): Promise { const campaign = await this.get(projectId, campaignId); // Can only cancel scheduled or sending campaigns if (campaign.status !== CampaignStatus.SCHEDULED && campaign.status !== CampaignStatus.SENDING) { throw new HttpException(400, 'Can only cancel scheduled or sending campaigns'); } // If scheduled, remove from queue if (campaign.status === CampaignStatus.SCHEDULED) { await QueueService.cancelScheduledCampaign(campaignId); } // Update status const cancelledCampaign = await prisma.campaign.update({ where: {id: campaignId}, data: { status: CampaignStatus.CANCELLED, }, include: { project: { select: {name: true}, }, }, }); // Send notification about campaign cancellation await NtfyService.notifyCampaignCancelled(cancelledCampaign.name, cancelledCampaign.project.name, projectId); return cancelledCampaign; } /** * Get campaign statistics */ public static async getStats(projectId: string, campaignId: string) { const campaign = await this.get(projectId, campaignId); // Get email stats from Email table const [sentEmails, deliveredEmails, openedEmails, clickedEmails, bouncedEmails] = await Promise.all([ prisma.email.count({ where: {campaignId, sentAt: {not: null}}, }), prisma.email.count({ where: {campaignId, deliveredAt: {not: null}}, }), prisma.email.count({ where: {campaignId, openedAt: {not: null}}, }), prisma.email.count({ where: {campaignId, clickedAt: {not: null}}, }), prisma.email.count({ where: {campaignId, bouncedAt: {not: null}}, }), ]); // Update campaign stats await prisma.campaign.update({ where: {id: campaignId}, data: { deliveredCount: deliveredEmails, openedCount: openedEmails, clickedCount: clickedEmails, bouncedCount: bouncedEmails, }, }); return { totalRecipients: campaign.totalRecipients, sentCount: sentEmails, deliveredCount: deliveredEmails, openedCount: openedEmails, clickedCount: clickedEmails, bouncedCount: bouncedEmails, openRate: sentEmails > 0 ? (openedEmails / sentEmails) * 100 : 0, clickRate: sentEmails > 0 ? (clickedEmails / sentEmails) * 100 : 0, bounceRate: sentEmails > 0 ? (bouncedEmails / sentEmails) * 100 : 0, deliveryRate: sentEmails > 0 ? (deliveredEmails / sentEmails) * 100 : 0, }; } /** * Send a test email for a campaign */ public static async sendTest(projectId: string, campaignId: string, testEmail: string): Promise { const campaign = await this.get(projectId, campaignId); // Validate that the test email belongs to a project member const membership = await prisma.membership.findFirst({ where: { projectId, user: { email: testEmail, }, }, include: { user: true, }, }); if (!membership) { throw new HttpException(403, 'Test emails can only be sent to project members'); } // Verify domain is registered and verified before sending await DomainService.verifyEmailDomain(campaign.from, projectId); // Get project to validate from address const project = await prisma.project.findUnique({ where: {id: projectId}, }); if (!project) { throw new HttpException(404, 'Project not found'); } // Prepare the email content (no variable replacement for test emails) await sendRawEmail({ from: { name: campaign.fromName || project.name || 'Plunk', email: campaign.from, }, to: [testEmail], content: { subject: `[TEST] ${campaign.subject}`, html: campaign.body, }, reply: campaign.replyTo || undefined, headers: { 'X-Plunk-Test': 'true', }, tracking: false, // Disable tracking for test emails }); } /** * Get recipient count for a campaign */ private static async getRecipientCount(projectId: string, campaign: Campaign): Promise { const where = await this.buildRecipientWhereAsync(projectId, campaign); return prisma.contact.count({where}); } /** * Get recipients for a campaign (legacy offset-based, kept for compatibility) */ private static async getRecipients( projectId: string, campaign: Campaign, offset: number, limit: number, ): Promise { const where = await this.buildRecipientWhereAsync(projectId, campaign); return prisma.contact.findMany({ where, skip: offset, take: limit, orderBy: {createdAt: 'asc'}, // Consistent ordering for batching }); } /** * Get recipients for a campaign using cursor-based pagination */ private static async getRecipientsCursor( projectId: string, campaign: Campaign, limit: number, cursor?: string, ): Promise<{contacts: Contact[]; nextCursor?: string; hasMore: boolean}> { const where = await this.buildRecipientWhereAsync(projectId, campaign); // Fetch one extra to determine if there are more results const contacts = await prisma.contact.findMany({ where, take: limit + 1, skip: cursor ? 1 : 0, cursor: cursor ? {id: cursor} : undefined, orderBy: {id: 'asc'}, // Use ID for consistent cursor ordering }); const hasMore = contacts.length > limit; const results = hasMore ? contacts.slice(0, -1) : contacts; const nextCursor = hasMore ? results[results.length - 1]?.id : undefined; return { contacts: results, nextCursor, hasMore, }; } /** * Build WHERE clause for campaign recipients (async for segment lookups) */ private static async buildRecipientWhereAsync( projectId: string, campaign: Campaign, ): Promise { const baseWhere: Prisma.ContactWhereInput = { projectId, subscribed: true, // Only send to subscribed contacts }; switch (campaign.audienceType) { case CampaignAudienceType.ALL: return baseWhere; case CampaignAudienceType.SEGMENT: if (!campaign.segmentId) { throw new HttpException(400, 'Segment ID is required for SEGMENT audience type'); } // Get segment and use its filters return this.buildSegmentWhereAsync(projectId, campaign.segmentId, baseWhere); case CampaignAudienceType.FILTERED: { const condition = campaign.audienceCondition as unknown as FilterCondition; if (!condition) { throw new HttpException(400, 'Audience condition is required for FILTERED audience type'); } // Use the SegmentService to build the where clause from the condition const segmentWhere = SegmentService.buildConditionClause(condition); return { ...baseWhere, ...segmentWhere, }; } default: throw new HttpException(400, 'Invalid audience type'); } } /** * Build WHERE clause for segment-based campaigns */ private static async buildSegmentWhereAsync( projectId: string, segmentId: string, baseWhere: Prisma.ContactWhereInput, ): Promise { // Fetch the segment to get its condition const segment = await prisma.segment.findUnique({ where: {id: segmentId}, }); if (!segment) { throw new HttpException(404, 'Segment not found'); } const condition = segment.condition as unknown as FilterCondition; const segmentWhere = SegmentService.buildConditionClause(condition); return { ...baseWhere, ...segmentWhere, }; } }