Initial push of Plunk Next

This commit is contained in:
Dries Augustyns
2025-12-01 09:56:56 +01:00
parent 07cea20262
commit ff1876d580
566 changed files with 89036 additions and 28423 deletions
@@ -0,0 +1,344 @@
import {describe, it, expect, beforeEach, vi} from 'vitest';
import {EmailStatus} from '@plunk/db';
import {factories, getPrismaClient, createServiceMocks} from '../../../../../test/helpers';
import type {Prisma} from '@plunk/db';
// Mock MeterService
vi.mock('../../services/MeterService.js', () => ({
MeterService: {
recordEmailSent: vi.fn().mockResolvedValue(undefined),
},
}));
describe('Email Processor', () => {
let projectId: string;
const prisma = getPrismaClient();
const _serviceMocks = createServiceMocks();
beforeEach(async () => {
const {project} = await factories.createUserWithProject({}, {trackingEnabled: true});
projectId = project.id;
});
describe('Email Processing', () => {
it('should process a pending email', async () => {
const contact = await factories.createContact({projectId});
const email = await factories.createEmail(projectId, contact.id, {
subject: 'Test Email',
body: '<p>Hello {{firstName}}</p>',
status: EmailStatus.PENDING,
});
// Mock the email processor logic
// In a real implementation, you would:
// 1. Create job tester
// 2. Mock SES service
// 3. Process the job
// 4. Verify status changes
// Simulate processing
await prisma.email.update({
where: {id: email.id},
data: {status: EmailStatus.SENDING},
});
// Simulate successful send
await prisma.email.update({
where: {id: email.id},
data: {
status: EmailStatus.SENT,
sentAt: new Date(),
},
});
const processed = await prisma.email.findUnique({where: {id: email.id}});
expect(processed?.status).toBe(EmailStatus.SENT);
expect(processed?.sentAt).toBeDefined();
});
it('should skip emails that are not pending', async () => {
const contact = await factories.createContact({projectId});
const email = await factories.createEmail(projectId, contact.id, {
status: EmailStatus.SENT, // Already sent
});
// Processor should skip this email
const shouldProcess = email.status === EmailStatus.PENDING;
expect(shouldProcess).toBe(false);
});
it('should fail email if project is disabled', async () => {
// Create project with disabled flag
const {project: disabledProject} = await factories.createUserWithProject({}, {disabled: true});
const contact = await factories.createContact({projectId: disabledProject.id});
const email = await factories.createEmail(disabledProject.id, contact.id, {
status: EmailStatus.PENDING,
});
// Verify project is disabled
const project = await prisma.project.findUnique({
where: {id: disabledProject.id},
});
expect(project?.disabled).toBe(true);
// Processor should fail this email
await prisma.email.update({
where: {id: email.id},
data: {
status: EmailStatus.FAILED,
error: 'Project is disabled',
},
});
const failed = await prisma.email.findUnique({where: {id: email.id}});
expect(failed?.status).toBe(EmailStatus.FAILED);
expect(failed?.error).toBe('Project is disabled');
});
it('should handle campaign emails', async () => {
const contact = await factories.createContact({projectId});
const campaign = await factories.createCampaign({projectId});
const email = await factories.createEmail(projectId, contact.id, {
campaignId: campaign.id,
status: EmailStatus.PENDING,
});
expect(email.campaignId).toBe(campaign.id);
// Process the email
await prisma.email.update({
where: {id: email.id},
data: {status: EmailStatus.SENT, sentAt: new Date()},
});
const sent = await prisma.email.findUnique({where: {id: email.id}});
expect(sent?.status).toBe(EmailStatus.SENT);
});
it('should handle transactional emails without unsubscribe', async () => {
const contact = await factories.createContact({projectId});
const template = await factories.createTemplate({
projectId,
type: 'TRANSACTIONAL',
});
await factories.createEmail(projectId, contact.id, {
templateId: template.id,
status: EmailStatus.PENDING,
});
expect(template.type).toBe('TRANSACTIONAL');
});
});
describe('Email Status Transitions', () => {
it('should transition PENDING -> SENDING -> SENT', async () => {
const contact = await factories.createContact({projectId});
const email = await factories.createEmail(projectId, contact.id, {
status: EmailStatus.PENDING,
});
expect(email.status).toBe(EmailStatus.PENDING);
// Transition to SENDING
await prisma.email.update({
where: {id: email.id},
data: {status: EmailStatus.SENDING},
});
let updated = await prisma.email.findUnique({where: {id: email.id}});
expect(updated?.status).toBe(EmailStatus.SENDING);
// Transition to SENT
await prisma.email.update({
where: {id: email.id},
data: {status: EmailStatus.SENT, sentAt: new Date()},
});
updated = await prisma.email.findUnique({where: {id: email.id}});
expect(updated?.status).toBe(EmailStatus.SENT);
expect(updated?.sentAt).toBeDefined();
});
it('should handle PENDING -> SENDING -> FAILED', async () => {
const contact = await factories.createContact({projectId});
const email = await factories.createEmail(projectId, contact.id, {
status: EmailStatus.PENDING,
});
// Transition to SENDING
await prisma.email.update({
where: {id: email.id},
data: {status: EmailStatus.SENDING},
});
// Fail with error
await prisma.email.update({
where: {id: email.id},
data: {
status: EmailStatus.FAILED,
error: 'SES send failed: Invalid email address',
},
});
const failed = await prisma.email.findUnique({where: {id: email.id}});
expect(failed?.status).toBe(EmailStatus.FAILED);
expect(failed?.error).toContain('SES send failed');
});
});
describe('Batch Processing', () => {
it('should handle multiple emails from a campaign', async () => {
const campaign = await factories.createCampaign({projectId});
const contacts = await factories.createContacts(projectId, 10);
// Create emails for all contacts
const emails = await Promise.all(
contacts.map(contact =>
factories.createEmail(projectId, contact.id, {
campaignId: campaign.id,
status: EmailStatus.PENDING,
}),
),
);
expect(emails).toHaveLength(10);
expect(emails.every(e => e.campaignId === campaign.id)).toBe(true);
// Simulate processing all emails
await Promise.all(
emails.map(email =>
prisma.email.update({
where: {id: email.id},
data: {status: EmailStatus.SENT, sentAt: new Date()},
}),
),
);
const processed = await prisma.email.findMany({
where: {campaignId: campaign.id},
});
expect(processed.every(e => e.status === EmailStatus.SENT)).toBe(true);
});
});
describe('Error Handling', () => {
it('should record error message on failure', async () => {
const contact = await factories.createContact({projectId});
const email = await factories.createEmail(projectId, contact.id, {
status: EmailStatus.PENDING,
});
// Simulate failure
const errorMessage = 'Failed to send: Rate limit exceeded';
await prisma.email.update({
where: {id: email.id},
data: {
status: EmailStatus.FAILED,
error: errorMessage,
},
});
const failed = await prisma.email.findUnique({where: {id: email.id}});
expect(failed?.status).toBe(EmailStatus.FAILED);
expect(failed?.error).toBe(errorMessage);
});
});
describe('Attachment Billing', () => {
it('should verify emails with attachments have attachment data', async () => {
const contact = await factories.createContact({projectId});
// Create email with attachments
const emailWithAttachments = await prisma.email.create({
data: {
projectId,
contactId: contact.id,
subject: 'Email with attachments',
body: '<p>Test email with attachments</p>',
from: '[email protected]',
status: EmailStatus.PENDING,
attachments: [
{
filename: 'document.pdf',
content: 'base64encodedcontent',
contentType: 'application/pdf',
},
] as unknown as Prisma.InputJsonValue,
},
});
// Create email without attachments
const emailWithoutAttachments = await factories.createEmail(projectId, contact.id, {
status: EmailStatus.PENDING,
});
// Verify attachments are stored correctly
const emailWithAttachmentsData = await prisma.email.findUnique({
where: {id: emailWithAttachments.id},
});
const emailWithoutAttachmentsData = await prisma.email.findUnique({
where: {id: emailWithoutAttachments.id},
});
expect(emailWithAttachmentsData?.attachments).toBeDefined();
expect(Array.isArray(emailWithAttachmentsData?.attachments)).toBe(true);
expect((emailWithAttachmentsData?.attachments as any[]).length).toBeGreaterThan(0);
expect(emailWithoutAttachmentsData?.attachments).toBeNull();
});
it('should verify attachment logic determines charging correctly', async () => {
const contact = await factories.createContact({projectId});
// Create email with attachments
const emailWithAttachments = await prisma.email.create({
data: {
projectId,
contactId: contact.id,
subject: 'Email with attachments',
body: '<p>Test</p>',
from: '[email protected]',
status: EmailStatus.PENDING,
attachments: [
{filename: 'file.pdf', content: 'base64', contentType: 'application/pdf'},
] as unknown as Prisma.InputJsonValue,
},
include: {
project: true,
contact: true,
},
});
// Simulate the logic from email-processor.ts
const hasAttachments =
emailWithAttachments.attachments &&
Array.isArray(emailWithAttachments.attachments) &&
emailWithAttachments.attachments.length > 0;
const emailCount = hasAttachments ? 2 : 1;
// Verify logic correctly identifies attachments
expect(hasAttachments).toBe(true);
expect(emailCount).toBe(2);
// Test without attachments
const emailWithoutAttachments = await factories.createEmail(projectId, contact.id, {
status: EmailStatus.PENDING,
});
const emailWithoutAttachmentsData = await prisma.email.findUnique({
where: {id: emailWithoutAttachments.id},
});
const hasNoAttachments =
emailWithoutAttachmentsData?.attachments &&
Array.isArray(emailWithoutAttachmentsData.attachments) &&
emailWithoutAttachmentsData.attachments.length > 0;
const emailCountNoAttachments = hasNoAttachments ? 2 : 1;
expect(hasNoAttachments).toBeFalsy();
expect(emailCountNoAttachments).toBe(1);
});
});
});
@@ -0,0 +1,227 @@
import {describe, it, expect, beforeEach, afterEach} from 'vitest';
import {CampaignStatus} from '@plunk/db';
import {factories, getPrismaClient, createTimeControl} from '../../../../../test/helpers';
describe('Scheduled Campaign Processor', () => {
let projectId: string;
const prisma = getPrismaClient();
const timeControl = createTimeControl();
beforeEach(async () => {
const {project} = await factories.createUserWithProject();
projectId = project.id;
});
afterEach(() => {
// Ensure time is always restored between tests
timeControl.restore();
});
describe('Campaign Scheduling', () => {
it('should execute campaign at scheduled time', async () => {
// Freeze time at a specific moment
timeControl.freeze(new Date('2025-01-20T09:00:00Z'));
// Schedule campaign for 1 hour from now
const scheduledTime = timeControl.helpers.relative(1, 'hour');
const campaign = await factories.createScheduledCampaign(projectId, scheduledTime, {
name: 'Scheduled Newsletter',
subject: 'Weekly Update',
});
expect(campaign.status).toBe(CampaignStatus.SCHEDULED);
expect(campaign.scheduledFor).toEqual(scheduledTime);
// Advance time to scheduled time
timeControl.advanceTo(scheduledTime);
// Verify we're at the right time
expect(timeControl.now()).toEqual(scheduledTime);
// At this point, the scheduled processor would pick up this campaign
// and change its status to SENDING
await prisma.campaign.update({
where: {id: campaign.id},
data: {status: CampaignStatus.SENDING},
});
const updatedCampaign = await prisma.campaign.findUnique({
where: {id: campaign.id},
});
expect(updatedCampaign?.status).toBe(CampaignStatus.SENDING);
timeControl.restore();
});
it('should not execute campaign before scheduled time', async () => {
timeControl.freeze(new Date('2025-01-20T09:00:00Z'));
// Schedule campaign for 2 hours from now
const scheduledTime = timeControl.helpers.relative(2, 'hour');
const campaign = await factories.createScheduledCampaign(projectId, scheduledTime);
// Advance time by only 1 hour (before scheduled time)
timeControl.helpers.advanceHours(1);
// Campaign should still be scheduled
const stillScheduled = await prisma.campaign.findUnique({
where: {id: campaign.id},
});
expect(stillScheduled?.status).toBe(CampaignStatus.SCHEDULED);
expect(stillScheduled?.scheduledFor).toEqual(scheduledTime);
timeControl.restore();
});
it('should handle multiple scheduled campaigns at different times', async () => {
timeControl.freeze(new Date('2025-01-20T10:00:00Z'));
// Create campaigns scheduled at different times
await factories.createScheduledCampaign(
projectId,
timeControl.helpers.relative(1, 'hour'), // 11:00
{name: 'Campaign 1'},
);
await factories.createScheduledCampaign(
projectId,
timeControl.helpers.relative(2, 'hour'), // 12:00
{name: 'Campaign 2'},
);
await factories.createScheduledCampaign(
projectId,
timeControl.helpers.relative(3, 'hour'), // 13:00
{name: 'Campaign 3'},
);
// Advance to 11:00 - first campaign should be processable
timeControl.helpers.advanceHours(1);
expect(timeControl.now().getUTCHours()).toBe(11);
// Advance to 12:00 - second campaign should be processable
timeControl.helpers.advanceHours(1);
expect(timeControl.now().getUTCHours()).toBe(12);
// Advance to 13:00 - third campaign should be processable
timeControl.helpers.advanceHours(1);
expect(timeControl.now().getUTCHours()).toBe(13);
// All campaigns should still be in scheduled state (until processor runs)
const campaigns = await prisma.campaign.findMany({
where: {projectId},
orderBy: {scheduledFor: 'asc'},
});
expect(campaigns).toHaveLength(3);
expect(campaigns.every(c => c.status === CampaignStatus.SCHEDULED)).toBe(true);
timeControl.restore();
});
});
describe('Campaign Status Transitions', () => {
it('should transition from SCHEDULED to SENDING', async () => {
timeControl.freeze(new Date('2025-01-20T10:00:00Z'));
const scheduledTime = timeControl.helpers.relative(30, 'minute');
const campaign = await factories.createCampaign({
projectId,
status: CampaignStatus.SCHEDULED,
scheduledFor: scheduledTime,
});
// Advance past scheduled time
timeControl.helpers.advanceMinutes(31);
// Processor would transition to SENDING
await prisma.campaign.update({
where: {id: campaign.id},
data: {status: CampaignStatus.SENDING},
});
const sending = await prisma.campaign.findUnique({
where: {id: campaign.id},
});
expect(sending?.status).toBe(CampaignStatus.SENDING);
timeControl.restore();
});
it('should eventually transition from SENDING to SENT', async () => {
const campaign = await factories.createCampaign({
projectId,
status: CampaignStatus.SENDING,
});
// Create some contacts and emails
const contact = await factories.createContact({projectId});
await factories.createEmail(projectId, contact.id, {
campaignId: campaign.id,
});
// Mark campaign as sent
await prisma.campaign.update({
where: {id: campaign.id},
data: {
status: CampaignStatus.SENT,
sentAt: new Date(),
},
});
const sent = await prisma.campaign.findUnique({
where: {id: campaign.id},
});
expect(sent?.status).toBe(CampaignStatus.SENT);
expect(sent?.sentAt).toBeDefined();
});
});
describe('Edge Cases', () => {
it('should handle campaigns scheduled in the past', async () => {
timeControl.freeze(new Date('2025-01-20T10:00:00Z'));
// Schedule campaign in the past
const pastTime = new Date('2025-01-19T10:00:00Z');
const campaign = await factories.createCampaign({
projectId,
status: CampaignStatus.SCHEDULED,
scheduledFor: pastTime,
});
// Processor should immediately pick this up
const shouldRun = campaign.scheduledFor && campaign.scheduledFor <= timeControl.now();
expect(shouldRun).toBe(true);
timeControl.restore();
});
it('should handle campaigns scheduled far in the future', async () => {
timeControl.freeze(new Date('2025-01-20T10:00:00Z'));
// Schedule campaign 30 days in the future
const futureTime = timeControl.helpers.relative(30, 'day');
const campaign = await factories.createScheduledCampaign(projectId, futureTime);
expect(campaign.status).toBe(CampaignStatus.SCHEDULED);
// Campaign should not be processable yet
const shouldNotRun = campaign.scheduledFor && campaign.scheduledFor > timeControl.now();
expect(shouldNotRun).toBe(true);
// Advance time by 29 days - still not ready
timeControl.helpers.advanceDays(29);
expect(campaign.scheduledFor! > timeControl.now()).toBe(true);
// Advance to exactly the scheduled time
timeControl.advanceTo(futureTime);
expect(timeControl.now()).toEqual(futureTime);
timeControl.restore();
});
});
});
@@ -0,0 +1,107 @@
import type {Job} from 'bullmq';
import {Worker} from 'bullmq';
import type {RedisOptions} from 'ioredis';
import signale from 'signale';
import {REDIS_URL} from '../app/constants.js';
import {prisma} from '../database/prisma.js';
import type {ApiRequestCleanupJobData} from '../services/QueueService.js';
/**
* API Request Cleanup Worker
* Deletes old API request logs to prevent unbounded table growth
* Runs daily to clean up logs older than 30 days (configurable)
*/
const RETENTION_DAYS = 30; // Keep logs for 30 days
const BATCH_SIZE = 10000; // Delete in batches for performance
/**
* Process API request cleanup job
*/
async function processCleanup(job: Job<ApiRequestCleanupJobData>): Promise<{deleted: number}> {
signale.info('[API-REQUEST-CLEANUP] Starting cleanup of old API request logs...');
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - RETENTION_DAYS);
let totalDeleted = 0;
let hasMore = true;
try {
// Delete in batches to avoid locking the table for too long
while (hasMore) {
const result = await prisma.apiRequest.deleteMany({
where: {
createdAt: {
lt: cutoffDate,
},
},
// Note: Prisma doesn't support LIMIT in deleteMany, so we use a different approach
});
totalDeleted += result.count;
// If we deleted fewer than batch size, we're done
hasMore = result.count >= BATCH_SIZE;
if (hasMore) {
signale.info(`[API-REQUEST-CLEANUP] Deleted ${totalDeleted} records so far, continuing...`);
// Small delay between batches to reduce database load
await new Promise(resolve => setTimeout(resolve, 100));
}
}
signale.success(
`[API-REQUEST-CLEANUP] Cleanup complete. Deleted ${totalDeleted} records older than ${RETENTION_DAYS} days (before ${cutoffDate.toISOString()})`,
);
// Update job progress
await job.updateProgress(100);
return {deleted: totalDeleted};
} catch (error) {
signale.error('[API-REQUEST-CLEANUP] Error during cleanup:', error);
throw error;
}
}
/**
* Create the API request cleanup worker
*/
export function createApiRequestCleanupWorker(): Worker<ApiRequestCleanupJobData> {
const redisConnection: RedisOptions = {
maxRetriesPerRequest: null,
enableReadyCheck: false,
...parseRedisUrl(REDIS_URL),
};
const worker = new Worker<ApiRequestCleanupJobData>('api-request-cleanup', processCleanup, {
connection: redisConnection,
concurrency: 1, // Only run one cleanup job at a time
});
worker.on('completed', job => {
signale.success(`[API-REQUEST-CLEANUP] Job ${job.id} completed:`, job.returnvalue);
});
worker.on('failed', (job, err) => {
signale.error(`[API-REQUEST-CLEANUP] Job ${job?.id} failed:`, err);
});
worker.on('error', err => {
signale.error('[API-REQUEST-CLEANUP] Worker error:', err);
});
return worker;
}
function parseRedisUrl(url: string): {host: string; port: number; password?: string; db?: number} {
const urlObj = new URL(url);
return {
host: urlObj.hostname,
port: parseInt(urlObj.port || '6379', 10),
password: urlObj.password || undefined,
db: parseInt(urlObj.pathname.slice(1) || '0', 10),
};
}
+42
View File
@@ -0,0 +1,42 @@
/**
* Background Job: Campaign Processor
* Processes campaign batches (queues emails for each contact in the batch)
*/
import {type Job, Worker} from 'bullmq';
import {CampaignService} from '../services/CampaignService.js';
import {type CampaignBatchJobData, campaignQueue} from '../services/QueueService.js';
export function createCampaignWorker() {
const worker = new Worker<CampaignBatchJobData>(
campaignQueue.name,
async (job: Job<CampaignBatchJobData>) => {
const {campaignId, batchNumber, offset, limit, cursor} = job.data;
console.log(`[CAMPAIGN-PROCESSOR] Processing batch ${batchNumber} for campaign ${campaignId}`);
await CampaignService.processBatch(campaignId, batchNumber, offset, limit, cursor);
console.log(`[CAMPAIGN-PROCESSOR] Completed batch ${batchNumber} for campaign ${campaignId}`);
},
{
connection: campaignQueue.opts.connection,
concurrency: 5, // Process up to 5 batches concurrently
},
);
worker.on('completed', job => {
console.log(`[CAMPAIGN-PROCESSOR] Job ${job.id} completed`);
});
worker.on('failed', (job, err) => {
console.error(`[CAMPAIGN-PROCESSOR] Job ${job?.id} failed:`, err.message);
});
worker.on('error', err => {
console.error('[CAMPAIGN-PROCESSOR] Worker error:', err);
});
return worker;
}
@@ -0,0 +1,60 @@
/**
* Domain Verification Worker
* Processes domain verification jobs from the BullMQ queue
*/
import {type Job, Worker} from 'bullmq';
import signale from 'signale';
import {type DomainVerificationJobData, domainVerificationQueue} from '../services/QueueService.js';
import {checkDomainVerifications} from './domain-verification.js';
/**
* Process domain verification job
*/
async function processDomainVerification(job: Job<DomainVerificationJobData>): Promise<void> {
signale.info(`[DOMAIN-VERIFICATION-WORKER] Starting domain verification job ${job.id}`);
try {
await checkDomainVerifications();
signale.success(`[DOMAIN-VERIFICATION-WORKER] Completed domain verification job ${job.id}`);
} catch (error) {
signale.error(`[DOMAIN-VERIFICATION-WORKER] Error processing job ${job.id}:`, error);
throw error; // Re-throw to trigger retry
}
}
/**
* Create and export the domain verification worker
*/
export function createDomainVerificationWorker(): Worker {
const worker = new Worker<DomainVerificationJobData>(
domainVerificationQueue.name,
async (job: Job<DomainVerificationJobData>) => {
await processDomainVerification(job);
},
{
connection: domainVerificationQueue.opts.connection,
concurrency: 1, // Process one domain verification job at a time
limiter: {
max: 1, // Max 1 job per duration
duration: 60000, // Per minute (prevents rapid-fire verification checks)
},
},
);
worker.on('completed', job => {
signale.success(`[DOMAIN-VERIFICATION-WORKER] Job ${job.id} completed`);
});
worker.on('failed', (job, error) => {
signale.error(`[DOMAIN-VERIFICATION-WORKER] Job ${job?.id} failed:`, error);
});
worker.on('error', error => {
signale.error('[DOMAIN-VERIFICATION-WORKER] Worker error:', error);
});
return worker;
}
+147
View File
@@ -0,0 +1,147 @@
/**
* Background Job: Domain Verification Checker
* Checks domain verification status with AWS SES
*
* This is processed by BullMQ workers (see domain-verification-processor.ts)
* Scheduled to run every 5 minutes via repeatable jobs
*/
import signale from 'signale';
import {prisma} from '../database/prisma.js';
import {redis} from '../database/redis.js';
import {disableFeedbackForwarding, getIdentities, verifyDomain} from '../services/SESService.js';
import {Keys} from '../services/keys.js';
/**
* Check verification status for all domains in the database
*/
export async function checkDomainVerifications() {
signale.info('[DOMAIN-VERIFICATION] Starting domain verification check...');
try {
const count = await prisma.domain.count();
signale.info(`[DOMAIN-VERIFICATION] Found ${count} domains to check`);
// Process domains in batches of 99 (AWS SES limit is 100)
for (let i = 0; i < count; i += 99) {
const domains = await prisma.domain.findMany({
select: {id: true, domain: true, projectId: true, verified: true},
skip: i,
take: 99,
});
if (domains.length === 0) {
continue;
}
// Get verification status from AWS SES
const sesIdentities = await getIdentities(domains.map(d => d.domain));
// Update each domain based on SES status
for (const sesIdentity of sesIdentities) {
const dbDomain = domains.find(d => d.domain === sesIdentity.domain);
if (!dbDomain) {
continue;
}
const isVerified = sesIdentity.status === 'Success';
// If domain failed verification, retry
if (sesIdentity.status === 'Failed') {
signale.warn(`[DOMAIN-VERIFICATION] Restarting verification for ${sesIdentity.domain}`);
let attempt = 0;
const maxAttempts = 5;
let success = false;
let delay = 5000;
while (attempt < maxAttempts && !success) {
try {
await verifyDomain(sesIdentity.domain);
success = true;
signale.success(`[DOMAIN-VERIFICATION] Restarted verification for ${sesIdentity.domain}`);
} catch (e: unknown) {
const error = e as {Code?: string; name?: string; message?: string};
if (error?.Code === 'Throttling' || error?.name === 'Throttling' || error?.message?.includes('Throttling')) {
signale.warn(
`[DOMAIN-VERIFICATION] Throttling detected, waiting ${delay / 1000} seconds (attempt ${attempt + 1})`,
);
await new Promise(r => setTimeout(r, delay));
delay *= 2; // Exponential backoff
attempt++;
} else {
signale.error(`[DOMAIN-VERIFICATION] Error restarting verification: ${error?.message || 'Unknown error'}`);
throw e;
}
}
}
if (!success) {
signale.error(
`[DOMAIN-VERIFICATION] Failed to verify ${sesIdentity.domain} after ${maxAttempts} attempts due to throttling`,
);
}
}
// Update verification status in database
await prisma.domain.update({
where: {id: dbDomain.id},
data: {verified: isVerified},
});
// If domain was just verified, disable feedback forwarding
if (!dbDomain.verified && isVerified) {
signale.success(`[DOMAIN-VERIFICATION] Domain ${sesIdentity.domain} is now verified!`);
try {
await disableFeedbackForwarding(sesIdentity.domain);
signale.info(`[DOMAIN-VERIFICATION] Disabled feedback forwarding for ${sesIdentity.domain}`);
} catch (error) {
signale.error(`[DOMAIN-VERIFICATION] Error disabling feedback forwarding: ${error}`);
}
// Invalidate cache
await redis.del(Keys.Domain.id(dbDomain.id));
await redis.del(Keys.Domain.project(dbDomain.projectId));
}
// If domain was unverified, invalidate cache
if (dbDomain.verified && !isVerified) {
signale.warn(`[DOMAIN-VERIFICATION] Domain ${sesIdentity.domain} is no longer verified`);
await redis.del(Keys.Domain.id(dbDomain.id));
await redis.del(Keys.Domain.project(dbDomain.projectId));
}
}
}
signale.success('[DOMAIN-VERIFICATION] Domain verification check completed');
} catch (error) {
signale.error('[DOMAIN-VERIFICATION] Error checking domain verifications:', error);
throw error;
}
}
/**
* Main processor function
* Call this from your scheduler/cron
*/
export async function runDomainVerificationJob() {
await checkDomainVerifications();
}
// If running this file directly (for testing or manual execution)
if (import.meta.url === `file://${process.argv[1]}`) {
signale.info('[DOMAIN-VERIFICATION] Running domain verification job manually...');
runDomainVerificationJob()
.then(() => {
signale.success('[DOMAIN-VERIFICATION] Completed successfully');
process.exit(0);
})
.catch(error => {
signale.error('[DOMAIN-VERIFICATION] Fatal error:', error);
process.exit(1);
});
}
+168
View File
@@ -0,0 +1,168 @@
/**
* Background Job: Email Processor
* Processes individual emails from the queue (for all sources: transactional, campaign, workflow)
*/
import type {Prisma} from '@plunk/db';
import {EmailSourceType, EmailStatus} from '@plunk/db';
import {type Job, Worker} from 'bullmq';
import {prisma} from '../database/prisma.js';
import {EmailService} from '../services/EmailService.js';
import {MeterService} from '../services/MeterService.js';
import {emailQueue, type SendEmailJobData} from '../services/QueueService.js';
import {sendRawEmail} from '../services/SESService.js';
export function createEmailWorker() {
const worker = new Worker<SendEmailJobData>(
emailQueue.name,
async (job: Job<SendEmailJobData>) => {
const {emailId} = job.data;
const email = await prisma.email.findUnique({
where: {id: emailId},
include: {
contact: true,
project: true,
},
});
if (!email) {
throw new Error(`Email ${emailId} not found`);
}
if (email.status !== EmailStatus.PENDING) {
return;
}
// Check if project is disabled
if (email.project.disabled) {
console.warn(`[EMAIL-PROCESSOR] Project ${email.projectId} is disabled, cancelling email ${emailId}`);
await prisma.email.update({
where: {id: emailId},
data: {
status: EmailStatus.FAILED,
error: 'Project is disabled',
},
});
return;
}
try {
// Update status to sending
await prisma.email.update({
where: {id: emailId},
data: {status: EmailStatus.SENDING},
});
// Format template variables in subject and body
const contactData = (email.contact.data as Record<string, unknown>) || {};
const formattedEmail = EmailService.format({
subject: email.subject,
body: email.body,
data: {
email: email.contact.email,
...contactData,
},
});
// Compile HTML with unsubscribe footer and badge
const compiledHtml = EmailService.compile({
content: formattedEmail.body,
contact: email.contact,
project: email.project,
includeUnsubscribe: email.sourceType !== EmailSourceType.TRANSACTIONAL, // Don't add unsubscribe to transactional emails
});
// Parse from email (format: "Name <[email protected]>" or just "[email protected]")
const fromMatch = /(.*?)<(.+?)>/.exec(email.from) || [null, email.from, email.from];
const fromName = fromMatch[1]?.trim() || email.project.name;
const fromEmail = fromMatch[2]?.trim() || email.from;
// Send via AWS SES
const result = await sendRawEmail({
from: {
name: fromName,
email: fromEmail,
},
to: [email.contact.email],
content: {
subject: formattedEmail.subject,
html: compiledHtml,
},
reply: email.replyTo || undefined,
tracking: email.project.trackingEnabled, // Use project's tracking preference
});
// Mark as sent with SES message ID
await prisma.email.update({
where: {id: emailId},
data: {
status: EmailStatus.SENT,
sentAt: new Date(),
messageId: result.messageId,
},
});
// Record usage for billing (pay-per-email)
// Uses email ID as idempotency key to prevent double-charging on retries
// Charge 2 emails if attachments are present
if (email.project.customer) {
const hasAttachments = email.attachments && Array.isArray(email.attachments) && email.attachments.length > 0;
const emailCount = hasAttachments ? 2 : 1;
await MeterService.recordEmailSent(email.project.customer, emailCount, `email_${emailId}`);
}
// Track event
await prisma.event.create({
data: {
projectId: email.projectId,
contactId: email.contactId,
emailId: email.id,
name: 'email.sent',
data: {
subject: formattedEmail.subject,
from: email.from,
messageId: result.messageId,
} as Prisma.InputJsonValue,
},
});
} catch (error) {
console.error(`[EMAIL-PROCESSOR] Failed to send email ${emailId}:`, error);
// Mark as failed
await prisma.email.update({
where: {id: emailId},
data: {
status: EmailStatus.FAILED,
error: error instanceof Error ? error.message : 'Unknown error',
},
});
throw error; // Re-throw to trigger retry
}
},
{
connection: emailQueue.opts.connection,
concurrency: 10, // Process up to 10 emails concurrently
limiter: {
max: 14, // Max 14 emails per second (AWS SES limit is typically 14/sec)
duration: 1000,
},
},
);
worker.on('completed', job => {
console.log(`[EMAIL-PROCESSOR] Job ${job.id} completed`);
});
worker.on('failed', (job, err) => {
console.error(`[EMAIL-PROCESSOR] Job ${job?.id} failed:`, err.message);
});
worker.on('error', err => {
console.error('[EMAIL-PROCESSOR] Worker error:', err);
});
return worker;
}
+176
View File
@@ -0,0 +1,176 @@
/**
* Background Job: Contact Import Processor
* Processes CSV contact imports with validation and batch processing
*/
import {type Job, Worker} from 'bullmq';
import {parse} from 'csv-parse/sync';
import {ContactService} from '../services/ContactService.js';
import {type ContactImportJobData, importQueue} from '../services/QueueService.js';
const BATCH_SIZE = 100; // Process contacts in batches of 100
interface ImportResult {
totalRows: number;
successCount: number;
createdCount: number;
updatedCount: number;
failureCount: number;
errors: {row: number; email: string; error: string}[];
}
export function createImportWorker() {
const worker = new Worker<ContactImportJobData>(
importQueue.name,
async (job: Job<ContactImportJobData>) => {
const {projectId, csvData, filename} = job.data;
console.log(`[IMPORT-PROCESSOR] Processing import for project ${projectId} (${filename})`);
const result: ImportResult = {
totalRows: 0,
successCount: 0,
createdCount: 0,
updatedCount: 0,
failureCount: 0,
errors: [],
};
try {
// Decode base64 CSV data
const csvContent = Buffer.from(csvData, 'base64').toString('utf-8');
// Parse CSV
const records = parse(csvContent, {
columns: true, // Use first row as header
skip_empty_lines: true,
trim: true,
relax_column_count: true, // Allow rows with different column counts
}) as Record<string, string>[];
result.totalRows = records.length;
// Validate row count
if (records.length === 0) {
throw new Error('CSV file is empty');
}
console.log(`[IMPORT-PROCESSOR] Parsed ${records.length} rows from CSV`);
// Validate that 'email' column exists
const firstRecord = records[0];
if (firstRecord && typeof firstRecord === 'object' && !('email' in firstRecord)) {
throw new Error('CSV must have an "email" column');
}
// Process contacts in batches
for (let i = 0; i < records.length; i += BATCH_SIZE) {
const batch = records.slice(i, Math.min(i + BATCH_SIZE, records.length));
// Process batch sequentially (to avoid overwhelming the database)
for (const [batchIndex, record] of batch.entries()) {
const rowNumber = i + batchIndex + 2; // +2 for header row and 1-based index
try {
// Validate email
const email = record.email?.trim();
if (!email) {
result.failureCount++;
result.errors.push({
row: rowNumber,
email: '',
error: 'Email is required',
});
continue;
}
// Basic email validation
if (!isValidEmail(email)) {
result.failureCount++;
result.errors.push({
row: rowNumber,
email,
error: 'Invalid email format',
});
continue;
}
// Extract custom data (all fields except email)
const {email: _, ...customData} = record;
const data = Object.keys(customData).length > 0 ? customData : undefined;
// Check if contact exists before upserting
const existingContact = await ContactService.findByEmail(projectId, email);
const isUpdate = !!existingContact;
// Upsert contact
await ContactService.upsert(projectId, email, data, true);
result.successCount++;
if (isUpdate) {
result.updatedCount++;
} else {
result.createdCount++;
}
} catch (error) {
result.failureCount++;
result.errors.push({
row: rowNumber,
email: record.email || '',
error: error instanceof Error ? error.message : 'Unknown error',
});
}
}
// Update progress
const progress = Math.round(((i + batch.length) / records.length) * 100);
await job.updateProgress(progress);
}
console.log(
`[IMPORT-PROCESSOR] Import completed: ${result.createdCount} created, ${result.updatedCount} updated, ${result.failureCount} failed`,
);
return result;
} catch (error) {
console.error(`[IMPORT-PROCESSOR] Failed to process import:`, error);
// Return partial results with error
result.errors.push({
row: 0,
email: '',
error: error instanceof Error ? error.message : 'Unknown error',
});
throw error; // Re-throw to mark job as failed
}
},
{
connection: importQueue.opts.connection,
concurrency: 2, // Process max 2 imports concurrently
},
);
worker.on('completed', job => {
console.log(`[IMPORT-PROCESSOR] Job ${job.id} completed`);
});
worker.on('failed', (job, err) => {
console.error(`[IMPORT-PROCESSOR] Job ${job?.id} failed:`, err.message);
});
worker.on('error', err => {
console.error('[IMPORT-PROCESSOR] Worker error:', err);
});
return worker;
}
/**
* Basic email validation
*/
function isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
+80
View File
@@ -0,0 +1,80 @@
/**
* Background Job: Scheduled Campaign Processor
* Processes scheduled campaigns when their time arrives
*/
import {CampaignStatus} from '@plunk/db';
import {type Job, Worker} from 'bullmq';
import {prisma} from '../database/prisma.js';
import {CampaignService} from '../services/CampaignService.js';
import {type ScheduledCampaignJobData, scheduledQueue} from '../services/QueueService.js';
export function createScheduledCampaignWorker() {
const worker = new Worker<ScheduledCampaignJobData>(
scheduledQueue.name,
async (job: Job<ScheduledCampaignJobData>) => {
const {campaignId} = job.data;
console.log(`[SCHEDULED-PROCESSOR] Processing scheduled campaign ${campaignId}`);
// Get campaign with project
const campaign = await prisma.campaign.findUnique({
where: {id: campaignId},
include: {
project: {
select: {disabled: true, id: true, name: true},
},
},
});
if (!campaign) {
console.warn(`[SCHEDULED-PROCESSOR] Campaign ${campaignId} not found, skipping`);
return;
}
// Check if project is disabled
if (campaign.project.disabled) {
console.warn(
`[SCHEDULED-PROCESSOR] Project ${campaign.projectId} (${campaign.project.name}) is disabled, cancelling campaign ${campaignId}`,
);
await prisma.campaign.update({
where: {id: campaignId},
data: {status: CampaignStatus.CANCELLED},
});
return;
}
// Verify campaign is still in SCHEDULED status
if (campaign.status !== CampaignStatus.SCHEDULED) {
console.warn(
`[SCHEDULED-PROCESSOR] Campaign ${campaignId} is not in SCHEDULED status (${campaign.status}), skipping`,
);
return;
}
// Start sending the campaign
await CampaignService.startSending(campaign.projectId, campaignId);
console.log(`[SCHEDULED-PROCESSOR] Started sending campaign ${campaignId}`);
},
{
connection: scheduledQueue.opts.connection,
concurrency: 2, // Process up to 2 scheduled campaigns concurrently
},
);
worker.on('completed', job => {
console.log(`[SCHEDULED-PROCESSOR] Job ${job.id} completed`);
});
worker.on('failed', (job, err) => {
console.error(`[SCHEDULED-PROCESSOR] Job ${job?.id} failed:`, err.message);
});
worker.on('error', err => {
console.error('[SCHEDULED-PROCESSOR] Worker error:', err);
});
return worker;
}
@@ -0,0 +1,150 @@
/**
* Segment Count Update Worker
* Processes segment count update jobs from the BullMQ queue
*/
import {type Job, Worker} from 'bullmq';
import signale from 'signale';
import {prisma} from '../database/prisma.js';
import {type SegmentCountJobData, segmentCountQueue} from '../services/QueueService.js';
import {SegmentService} from '../services/SegmentService.js';
/**
* Process segments for a single project
* - For segments with trackMembership: compute full membership and trigger events
* - For segments without trackMembership: only update counts
*/
async function processProjectSegments(projectId: string, projectName?: string): Promise<void> {
const logPrefix = projectName ? `${projectName} (${projectId})` : projectId;
// Get all segments for this project, separating tracked vs non-tracked
const segments = await prisma.segment.findMany({
where: {projectId},
select: {id: true, name: true, trackMembership: true},
});
const trackedSegments = segments.filter(s => s.trackMembership);
const nonTrackedSegments = segments.filter(s => !s.trackMembership);
signale.info(
`[SEGMENT-COUNT-WORKER] Project ${logPrefix}: ${trackedSegments.length} tracked, ${nonTrackedSegments.length} non-tracked segments`,
);
// Process tracked segments with full membership computation (creates events)
if (trackedSegments.length > 0) {
for (const segment of trackedSegments) {
try {
signale.info(
`[SEGMENT-COUNT-WORKER] Computing membership for tracked segment "${segment.name}" (${segment.id})`,
);
const result = await SegmentService.computeMembership(projectId, segment.id);
signale.success(
`[SEGMENT-COUNT-WORKER] Segment "${segment.name}": +${result.added} entries, -${result.removed} exits, ${result.total} total members`,
);
} catch (error) {
signale.error(`[SEGMENT-COUNT-WORKER] Failed to compute membership for segment ${segment.id}:`, error);
// Continue with other segments
}
}
}
// Process non-tracked segments with count-only update (lightweight)
if (nonTrackedSegments.length > 0) {
try {
await SegmentService.refreshAllMemberCounts(projectId);
signale.info(`[SEGMENT-COUNT-WORKER] Updated counts for ${nonTrackedSegments.length} non-tracked segments`);
} catch (error) {
signale.error(`[SEGMENT-COUNT-WORKER] Failed to update counts for non-tracked segments:`, error);
}
}
}
/**
* Process segment count update job
*/
async function processSegmentCountUpdate(job: Job<SegmentCountJobData>): Promise<void> {
const {projectId} = job.data;
signale.info(`[SEGMENT-COUNT-WORKER] Starting segment count update job ${job.id}`);
try {
if (projectId) {
// Process specific project
signale.info(`[SEGMENT-COUNT-WORKER] Processing segments for project ${projectId}`);
await processProjectSegments(projectId);
signale.success(`[SEGMENT-COUNT-WORKER] Completed segments for project ${projectId}`);
} else {
// Process all active projects
const projects = await prisma.project.findMany({
where: {disabled: false},
select: {id: true, name: true},
});
signale.info(`[SEGMENT-COUNT-WORKER] Found ${projects.length} active projects`);
// Process projects in batches to avoid overwhelming the database
const PROJECT_BATCH_SIZE = 10;
for (let i = 0; i < projects.length; i += PROJECT_BATCH_SIZE) {
const batch = projects.slice(i, i + PROJECT_BATCH_SIZE);
await Promise.all(
batch.map(async project => {
try {
signale.info(`[SEGMENT-COUNT-WORKER] Processing project ${project.name} (${project.id})`);
await processProjectSegments(project.id, project.name);
signale.success(`[SEGMENT-COUNT-WORKER] Completed project ${project.name}`);
} catch (error) {
signale.error(`[SEGMENT-COUNT-WORKER] Failed to process project ${project.id}:`, error);
// Don't throw - continue with other projects
}
}),
);
// Small delay between project batches to avoid overwhelming the database
if (i + PROJECT_BATCH_SIZE < projects.length) {
await new Promise(resolve => setTimeout(resolve, 2000));
}
}
signale.success(`[SEGMENT-COUNT-WORKER] Completed all segment updates`);
}
} catch (error) {
signale.error(`[SEGMENT-COUNT-WORKER] Error processing job ${job.id}:`, error);
throw error; // Re-throw to trigger retry
}
}
/**
* Create and export the segment count worker
*/
export function createSegmentCountWorker(): Worker {
const worker = new Worker<SegmentCountJobData>(
segmentCountQueue.name,
async (job: Job<SegmentCountJobData>) => {
await processSegmentCountUpdate(job);
},
{
connection: segmentCountQueue.opts.connection,
concurrency: 1, // Process one segment count job at a time to avoid database overload
limiter: {
max: 1, // Max 1 job per duration
duration: 60000, // Per minute (prevents rapid-fire updates)
},
},
);
worker.on('completed', job => {
signale.success(`[SEGMENT-COUNT-WORKER] Job ${job.id} completed`);
});
worker.on('failed', (job, error) => {
signale.error(`[SEGMENT-COUNT-WORKER] Job ${job?.id} failed:`, error);
});
worker.on('error', error => {
signale.error('[SEGMENT-COUNT-WORKER] Worker error:', error);
});
return worker;
}
+112
View File
@@ -0,0 +1,112 @@
/**
* Unified Queue Worker
* Starts all queue processors (email, campaign, scheduled, workflow, import, segment-count, domain-verification)
*
* This should be run as a separate process in production:
* node dist/jobs/worker.js
*/
import {Worker} from 'bullmq';
import signale from 'signale';
import {createApiRequestCleanupWorker} from './api-request-cleanup-processor.js';
import {createCampaignWorker} from './campaign-processor.js';
import {createDomainVerificationWorker} from './domain-verification-processor.js';
import {createEmailWorker} from './email-processor.js';
import {createImportWorker} from './import-processor.js';
import {createScheduledCampaignWorker} from './scheduled-processor.js';
import {createSegmentCountWorker} from './segment-count-processor.js';
import {createWorkflowWorker} from './workflow-processor-queue.js';
const workers: {name: string; worker: Worker}[] = [];
async function startWorkers() {
signale.info('[WORKER] Starting queue workers...');
try {
// Start email worker
const emailWorker = createEmailWorker();
workers.push({name: 'email', worker: emailWorker});
signale.success('[WORKER] Email worker started');
// Start campaign worker
const campaignWorker = createCampaignWorker();
workers.push({name: 'campaign', worker: campaignWorker});
signale.success('[WORKER] Campaign worker started');
// Start scheduled campaign worker
const scheduledWorker = createScheduledCampaignWorker();
workers.push({name: 'scheduled', worker: scheduledWorker});
signale.success('[WORKER] Scheduled campaign worker started');
// Start workflow worker
const workflowWorker = createWorkflowWorker();
workers.push({name: 'workflow', worker: workflowWorker});
signale.success('[WORKER] Workflow worker started');
// Start import worker
const importWorker = createImportWorker();
workers.push({name: 'import', worker: importWorker});
signale.success('[WORKER] Import worker started');
// Start segment count worker
const segmentCountWorker = createSegmentCountWorker();
workers.push({name: 'segment-count', worker: segmentCountWorker});
signale.success('[WORKER] Segment count worker started');
// Start domain verification worker
const domainVerificationWorker = createDomainVerificationWorker();
workers.push({name: 'domain-verification', worker: domainVerificationWorker});
signale.success('[WORKER] Domain verification worker started');
// Start API request cleanup worker
const apiRequestCleanupWorker = createApiRequestCleanupWorker();
workers.push({name: 'api-request-cleanup', worker: apiRequestCleanupWorker});
signale.success('[WORKER] API request cleanup worker started');
signale.success('[WORKER] All workers started successfully');
} catch (error) {
signale.error('[WORKER] Failed to start workers:', error);
process.exit(1);
}
}
async function stopWorkers() {
signale.info('[WORKER] Stopping workers...');
for (const {name, worker} of workers) {
try {
await worker.close();
signale.info(`[WORKER] ${name} worker stopped`);
} catch (error) {
signale.error(`[WORKER] Error stopping ${name} worker:`, error);
}
}
signale.success('[WORKER] All workers stopped');
process.exit(0);
}
// Handle graceful shutdown
process.on('SIGINT', () => {
signale.info('[WORKER] Received SIGINT, shutting down gracefully...');
void stopWorkers();
});
process.on('SIGTERM', () => {
signale.info('[WORKER] Received SIGTERM, shutting down gracefully...');
void stopWorkers();
});
process.on('uncaughtException', error => {
signale.error('[WORKER] Uncaught exception:', error);
void stopWorkers();
});
process.on('unhandledRejection', (reason, promise) => {
signale.error('[WORKER] Unhandled rejection at:', promise, 'reason:', reason);
void stopWorkers();
});
// Start workers
void startWorkers();
@@ -0,0 +1,48 @@
/**
* Background Job: Workflow Queue Processor
* Processes workflow steps from the queue (for delayed steps)
*/
import {type Job, Worker} from 'bullmq';
import {workflowQueue, type WorkflowStepJobData} from '../services/QueueService.js';
import {WorkflowExecutionService} from '../services/WorkflowExecutionService.js';
export function createWorkflowWorker() {
const worker = new Worker<WorkflowStepJobData>(
workflowQueue.name,
async (job: Job<WorkflowStepJobData>) => {
const {executionId, stepId, type, stepExecutionId} = job.data;
if (type === 'timeout') {
// Handle timeout for WAIT_FOR_EVENT steps
if (!stepExecutionId) {
throw new Error('stepExecutionId is required for timeout jobs');
}
await WorkflowExecutionService.processTimeout(executionId, stepId, stepExecutionId);
} else {
// Handle regular step execution
await WorkflowExecutionService.processStepExecution(executionId, stepId);
}
},
{
connection: workflowQueue.opts.connection,
concurrency: 10, // Process up to 10 workflow steps concurrently
},
);
worker.on('completed', job => {
console.log(`[WORKFLOW-PROCESSOR] Job ${job.id} completed`);
});
worker.on('failed', (job, err) => {
console.error(`[WORKFLOW-PROCESSOR] Job ${job?.id} failed:`, err.message);
});
worker.on('error', err => {
console.error('[WORKFLOW-PROCESSOR] Worker error:', err);
});
return worker;
}