diff --git a/apps/api/src/jobs/email-processor.ts b/apps/api/src/jobs/email-processor.ts index 4a4e22a..64071f4 100644 --- a/apps/api/src/jobs/email-processor.ts +++ b/apps/api/src/jobs/email-processor.ts @@ -3,7 +3,6 @@ * 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'; @@ -13,6 +12,7 @@ import {EventService} from '../services/EventService.js'; import {MeterService} from '../services/MeterService.js'; import {emailQueue, type SendEmailJobData} from '../services/QueueService.js'; import {sendRawEmail} from '../services/SESService.js'; +import {DASHBOARD_URI} from '../app/constants.js'; export function createEmailWorker() { const worker = new Worker( @@ -64,6 +64,10 @@ export function createEmailWorker() { data: { email: email.contact.email, ...contactData, + data: contactData, + unsubscribeUrl: `${DASHBOARD_URI}/unsubscribe/${email.contact.id}`, + subscribeUrl: `${DASHBOARD_URI}/subscribe/${email.contact.id}`, + manageUrl: `${DASHBOARD_URI}/manage/${email.contact.id}`, }, }); diff --git a/apps/api/src/services/CampaignService.ts b/apps/api/src/services/CampaignService.ts index 4374f7f..6ea24f4 100644 --- a/apps/api/src/services/CampaignService.ts +++ b/apps/api/src/services/CampaignService.ts @@ -10,6 +10,8 @@ import {DomainService} from './DomainService.js'; import {EmailService} from './EmailService.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) @@ -369,6 +371,10 @@ export class CampaignService { 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({ @@ -505,6 +511,60 @@ export class CampaignService { }; } + /** + * 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 */ @@ -632,60 +692,4 @@ export class CampaignService { ...segmentWhere, }; } - - /** - * 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) - const {sendRawEmail} = await import('./SESService.js'); - - 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 - }); - } } diff --git a/apps/api/src/services/ContactService.ts b/apps/api/src/services/ContactService.ts index 474bd26..b345c31 100644 --- a/apps/api/src/services/ContactService.ts +++ b/apps/api/src/services/ContactService.ts @@ -2,6 +2,7 @@ import {type Contact, Prisma} from '@plunk/db'; import {prisma} from '../database/prisma.js'; import {HttpException} from '../exceptions/index.js'; +import {EventService} from './EventService.js'; export interface PaginatedContacts { contacts: Contact[]; @@ -155,7 +156,6 @@ export class ContactService { // Track subscription event if status changed if (isSubscriptionChanging) { - const {EventService} = await import('./EventService.js'); if (data.subscribed && !wasSubscribed) { await EventService.trackEvent(projectId, 'contact.subscribed', contactId); } else if (!data.subscribed && wasSubscribed) { @@ -263,7 +263,6 @@ export class ContactService { // Track subscription event if status changed if (isSubscriptionChanging) { - const {EventService} = await import('./EventService.js'); if (subscribed && !wasSubscribed) { await EventService.trackEvent(projectId, 'contact.subscribed', updated.id); } else if (!subscribed && wasSubscribed) { @@ -351,7 +350,6 @@ export class ContactService { }); // Track subscription event - const {EventService} = await import('./EventService.js'); await EventService.trackEvent(contact.projectId, 'contact.subscribed', contactId); return contact; @@ -367,7 +365,6 @@ export class ContactService { }); // Track unsubscription event - const {EventService} = await import('./EventService.js'); await EventService.trackEvent(contact.projectId, 'contact.unsubscribed', contactId); return contact; diff --git a/apps/api/src/services/EmailService.ts b/apps/api/src/services/EmailService.ts index ca71428..79695e8 100644 --- a/apps/api/src/services/EmailService.ts +++ b/apps/api/src/services/EmailService.ts @@ -333,6 +333,10 @@ export class EmailService { data: { email: email.contact.email, ...contactData, + data: contactData, + unsubscribeUrl: `${DASHBOARD_URI}/unsubscribe/${email.contact.id}`, + subscribeUrl: `${DASHBOARD_URI}/subscribe/${email.contact.id}`, + manageUrl: `${DASHBOARD_URI}/manage/${email.contact.id}`, }, }); @@ -466,7 +470,6 @@ export class EmailService { data: {subscribed: false}, }); // Track unsubscription event - const {EventService} = await import('./EventService.js'); await EventService.trackEvent(email.projectId, 'contact.unsubscribed', email.contactId, email.id, { reason: 'bounce', }); @@ -483,7 +486,6 @@ export class EmailService { data: {subscribed: false}, }); // Track unsubscription event - const {EventService} = await import('./EventService.js'); await EventService.trackEvent(email.projectId, 'contact.unsubscribed', email.contactId, email.id, { reason: 'complaint', }); diff --git a/apps/api/src/services/QueueService.ts b/apps/api/src/services/QueueService.ts index b91a255..77ed1b0 100644 --- a/apps/api/src/services/QueueService.ts +++ b/apps/api/src/services/QueueService.ts @@ -2,6 +2,7 @@ import {type Job, Queue} from 'bullmq'; import type {RedisOptions} from 'ioredis'; import {REDIS_URL} from '../app/constants.js'; +import {prisma} from '../database/prisma.js'; /** * Queue Job Data Types @@ -441,7 +442,6 @@ export class QueueService { for (const job of scheduledCampaigns) { // We need to check if the campaign belongs to this project // by looking up the campaign in the database - const {prisma} = await import('../database/prisma.js'); const campaign = await prisma.campaign.findUnique({ where: {id: job.data.campaignId}, select: {projectId: true}, @@ -456,7 +456,6 @@ export class QueueService { // Cancel all pending emails for this project const pendingEmails = await emailQueue.getJobs(['waiting', 'delayed']); for (const job of pendingEmails) { - const {prisma} = await import('../database/prisma.js'); const email = await prisma.email.findUnique({ where: {id: job.data.emailId}, select: {projectId: true}, @@ -471,7 +470,6 @@ export class QueueService { // Cancel all pending campaign batches for this project const campaignBatches = await campaignQueue.getJobs(['waiting', 'delayed']); for (const job of campaignBatches) { - const {prisma} = await import('../database/prisma.js'); const campaign = await prisma.campaign.findUnique({ where: {id: job.data.campaignId}, select: {projectId: true}, @@ -486,7 +484,6 @@ export class QueueService { // Cancel all pending workflow steps for this project const workflowSteps = await workflowQueue.getJobs(['waiting', 'delayed']); for (const job of workflowSteps) { - const {prisma} = await import('../database/prisma.js'); const execution = await prisma.workflowExecution.findUnique({ where: {id: job.data.executionId}, select: {workflow: {select: {projectId: true}}}, diff --git a/apps/api/src/services/SecurityService.ts b/apps/api/src/services/SecurityService.ts index 3ced697..03b0873 100644 --- a/apps/api/src/services/SecurityService.ts +++ b/apps/api/src/services/SecurityService.ts @@ -2,6 +2,7 @@ import signale from 'signale'; import {prisma} from '../database/prisma.js'; import {redis} from '../database/redis.js'; +import {QueueService} from './QueueService.js'; /** * Security thresholds for bounce and complaint rates @@ -324,7 +325,6 @@ export class SecurityService { // Cancel all pending jobs for this project try { - const {QueueService} = await import('./QueueService.js'); await QueueService.cancelAllProjectJobs(projectId); signale.info(`[SECURITY] Cancelled all pending jobs for project ${projectId}`); } catch (error) { diff --git a/apps/api/src/services/WorkflowExecutionService.ts b/apps/api/src/services/WorkflowExecutionService.ts index ecf22ac..c63ef22 100644 --- a/apps/api/src/services/WorkflowExecutionService.ts +++ b/apps/api/src/services/WorkflowExecutionService.ts @@ -12,6 +12,7 @@ import {WorkflowStepConfigSchemas} from '@plunk/shared'; import {prisma} from '../database/prisma.js'; import {HttpException} from '../exceptions/index.js'; +import {DASHBOARD_URI} from '../app/constants.js'; import {EmailService} from './EmailService.js'; import {QueueService} from './QueueService.js'; @@ -446,6 +447,10 @@ export class WorkflowExecutionService { email: contact.email, ...contactData, ...executionContext, + data: contactData, + unsubscribeUrl: `${DASHBOARD_URI}/unsubscribe/${contact.id}`, + subscribeUrl: `${DASHBOARD_URI}/subscribe/${contact.id}`, + manageUrl: `${DASHBOARD_URI}/manage/${contact.id}`, }; const renderedSubject = this.renderTemplate(step.template.subject, variables); diff --git a/apps/api/src/services/WorkflowService.ts b/apps/api/src/services/WorkflowService.ts index 9ecbff0..17a5af3 100644 --- a/apps/api/src/services/WorkflowService.ts +++ b/apps/api/src/services/WorkflowService.ts @@ -6,6 +6,7 @@ import {HttpException} from '../exceptions/index.js'; import {EventService} from './EventService.js'; import {ContactService} from './ContactService.js'; +import {WorkflowExecutionService} from './WorkflowExecutionService.js'; export interface PaginatedWorkflows { workflows: Workflow[]; @@ -749,7 +750,6 @@ export class WorkflowService { // Start executing the workflow asynchronously // Don't await - let it run in background - const {WorkflowExecutionService} = await import('./WorkflowExecutionService.js'); WorkflowExecutionService.processStepExecution(execution.id, triggerStep.id).catch(error => { console.error('Error executing workflow:', error); }); diff --git a/apps/landing/next-env.d.ts b/apps/landing/next-env.d.ts index 1970904..7996d35 100644 --- a/apps/landing/next-env.d.ts +++ b/apps/landing/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/pages/api-reference/config/typescript for more information.