Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/api/src/controllers/Actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ export class Actions {
* - name: string (optional) - Sender name (alternative to from.name)
* - from: string | object (optional) - Sender email or {name, email} object (must be from verified domain)
* - reply: string (optional) - Reply-to email
* - tracking: boolean (optional) - Override the project's open/click tracking mode for this email (uses project setting if omitted)
* - headers: object (optional) - Additional email headers
* - data: object (optional) - Contact data and template variables
* - Simple values are saved to contact (persistent)
Expand Down Expand Up @@ -185,7 +186,7 @@ export class Actions {
const auth = res.locals.auth;

// Zod validation - errors automatically handled by global error handler
const {to, subject, body, subscribed, name, from, reply, headers, data, template, attachments} =
const {to, subject, body, subscribed, name, from, reply, tracking, headers, data, template, attachments} =
ActionSchemas.send.parse(req.body);

// Normalize recipients to array and parse email/name
Expand Down Expand Up @@ -336,6 +337,7 @@ export class Actions {
headers: headers || undefined,
attachments: attachments || undefined,
templateId: templateId,
tracking: tracking,
});

emailResults.push({
Expand Down
73 changes: 73 additions & 0 deletions apps/api/src/jobs/__tests__/email-processor.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import {beforeEach, describe, expect, it, vi} from 'vitest';
import {EmailSourceType, EmailStatus, TrackingMode} from '@plunk/db';
import type {SendEmailJobData} from '@plunk/types';
import {toPrismaJson} from '@plunk/types';
import type {Job} from 'bullmq';
import {processEmailJob} from '../email-processor';
import {sendRawEmail} from '../../services/SESService';
import {createServiceMocks, factories, getPrismaClient} from '../../../../../test/helpers';

// Mock MeterService
Expand All @@ -10,6 +14,12 @@ vi.mock('../../services/MeterService.js', () => ({
},
}));

// Mock SES service so processEmailJob can run the real worker send path
vi.mock('../../services/SESService', () => ({
sendRawEmail: vi.fn(),
getSendingQuota: vi.fn().mockResolvedValue(null),
}));

describe('Email Processor', () => {
let projectId: string;
const prisma = getPrismaClient();
Expand Down Expand Up @@ -337,4 +347,67 @@ describe('Email Processor', () => {
expect(emailCountNoAttachments).toBe(1);
});
});

describe('Per-Email Tracking Override (worker send path)', () => {
const runJob = (emailId: string) => processEmailJob({data: {emailId}} as Job<SendEmailJobData>);

beforeEach(() => {
vi.mocked(sendRawEmail).mockClear();
vi.mocked(sendRawEmail).mockResolvedValue({messageId: 'ses-worker-123'});
});

it('should disable tracking when trackingOverride is false, even with project tracking ENABLED', async () => {
const contact = await factories.createContact({projectId});
const email = await factories.createEmail({
projectId,
contactId: contact.id,
status: EmailStatus.PENDING,
trackingOverride: false,
});

await runJob(email.id);

expect(vi.mocked(sendRawEmail)).toHaveBeenCalledWith(expect.objectContaining({tracking: false}));

const sent = await prisma.email.findUnique({where: {id: email.id}});
expect(sent?.status).toBe(EmailStatus.SENT);
});

it('should enable tracking when trackingOverride is true, even with project tracking DISABLED', async () => {
await prisma.project.update({
where: {id: projectId},
data: {tracking: TrackingMode.DISABLED},
});

const contact = await factories.createContact({projectId});
const email = await factories.createEmail({
projectId,
contactId: contact.id,
status: EmailStatus.PENDING,
trackingOverride: true,
});

await runJob(email.id);

expect(vi.mocked(sendRawEmail)).toHaveBeenCalledWith(expect.objectContaining({tracking: true}));
});

it('should fall back to project tracking mode when trackingOverride is null', async () => {
await prisma.project.update({
where: {id: projectId},
data: {tracking: TrackingMode.DISABLED},
});

const contact = await factories.createContact({projectId});
const email = await factories.createEmail({
projectId,
contactId: contact.id,
status: EmailStatus.PENDING,
});

await runJob(email.id);

expect(vi.mocked(sendRawEmail)).toHaveBeenCalledWith(expect.objectContaining({tracking: false}));
});
});
});
Loading