feat: Add support for custom email recipients in workflow steps

This commit is contained in:
Dries Augustyns
2026-02-17 18:57:15 +01:00
parent f9b1354460
commit 78d3d224af
7 changed files with 1274 additions and 736 deletions
+18 -2
View File
@@ -117,10 +117,25 @@ export async function createEmailWorker() {
const fromName = email.fromName || email.project.name; const fromName = email.fromName || email.project.name;
const fromEmail = email.from; const fromEmail = email.from;
// Parse custom headers from JSON
const customHeaders =
email.headers && typeof email.headers === 'object' && !Array.isArray(email.headers)
? (email.headers as Record<string, string>)
: undefined;
// Check for custom recipient override in headers
const recipientEmail = customHeaders?.['X-Plunk-Recipient-Override'] || email.contact.email;
// Remove internal headers before sending
const publicHeaders = customHeaders ? {...customHeaders} : undefined;
if (publicHeaders && 'X-Plunk-Recipient-Override' in publicHeaders) {
delete publicHeaders['X-Plunk-Recipient-Override'];
}
// Build recipient with name if available // Build recipient with name if available
const recipient: {name?: string; email: string} | string = email.toName const recipient: {name?: string; email: string} | string = email.toName
? {name: email.toName, email: email.contact.email} ? {name: email.toName, email: recipientEmail}
: email.contact.email; : recipientEmail;
// Determine tracking based on project settings and email type // Determine tracking based on project settings and email type
const shouldTrack = EmailService.shouldTrackEmail(email.project.tracking, email.sourceType); const shouldTrack = EmailService.shouldTrackEmail(email.project.tracking, email.sourceType);
@@ -137,6 +152,7 @@ export async function createEmailWorker() {
html: compiledHtml, html: compiledHtml,
}, },
reply: email.replyTo || undefined, reply: email.replyTo || undefined,
headers: publicHeaders,
tracking: shouldTrack, tracking: shouldTrack,
attachments: email.attachments as {filename: string; content: string; contentType: string}[] | null, attachments: email.attachments as {filename: string; content: string; contentType: string}[] | null,
}); });
+21 -4
View File
@@ -35,6 +35,7 @@ interface SendEmailParams {
campaignId?: string; campaignId?: string;
workflowExecutionId?: string; workflowExecutionId?: string;
workflowStepExecutionId?: string; workflowStepExecutionId?: string;
recipientEmail?: string; // Optional custom recipient email (overrides contact.email)
} }
/** /**
@@ -193,7 +194,8 @@ export class EmailService {
// Check subscription status for marketing emails // Check subscription status for marketing emails
// Transactional emails should always be sent regardless of subscription status // Transactional emails should always be sent regardless of subscription status
if (sourceType !== EmailSourceType.TRANSACTIONAL) { // Custom recipient emails also bypass subscription checks (they're not in the contact list)
if (sourceType !== EmailSourceType.TRANSACTIONAL && !params.recipientEmail) {
const contact = await prisma.contact.findUnique({ const contact = await prisma.contact.findUnique({
where: {id: params.contactId}, where: {id: params.contactId},
select: {subscribed: true}, select: {subscribed: true},
@@ -242,6 +244,12 @@ export class EmailService {
signale.warn(`[BILLING_LIMIT] ${limitCheck.message}`); signale.warn(`[BILLING_LIMIT] ${limitCheck.message}`);
} }
// If custom recipient email is provided, store it in headers for later use
const emailHeaders = params.headers ? {...params.headers} : {};
if (params.recipientEmail) {
emailHeaders['X-Plunk-Recipient-Override'] = params.recipientEmail;
}
const email = await prisma.email.create({ const email = await prisma.email.create({
data: { data: {
projectId: params.projectId, projectId: params.projectId,
@@ -251,7 +259,7 @@ export class EmailService {
from: params.from, from: params.from,
fromName: params.fromName, fromName: params.fromName,
replyTo: params.replyTo, replyTo: params.replyTo,
headers: params.headers ? toPrismaJson(params.headers) : undefined, headers: Object.keys(emailHeaders).length > 0 ? toPrismaJson(emailHeaders) : undefined,
attachments: params.attachments ? toPrismaJson(params.attachments) : undefined, attachments: params.attachments ? toPrismaJson(params.attachments) : undefined,
sourceType, sourceType,
templateId: params.templateId, templateId: params.templateId,
@@ -361,6 +369,15 @@ export class EmailService {
? (email.headers as Record<string, string>) ? (email.headers as Record<string, string>)
: undefined; : undefined;
// Check for custom recipient override in headers
const recipientEmail = customHeaders?.['X-Plunk-Recipient-Override'] || email.contact.email;
// Remove internal headers before sending
const publicHeaders = customHeaders ? {...customHeaders} : undefined;
if (publicHeaders && 'X-Plunk-Recipient-Override' in publicHeaders) {
delete publicHeaders['X-Plunk-Recipient-Override'];
}
// Parse attachments from JSON // Parse attachments from JSON
const attachments = const attachments =
email.attachments && Array.isArray(email.attachments) email.attachments && Array.isArray(email.attachments)
@@ -376,13 +393,13 @@ export class EmailService {
name: fromName, name: fromName,
email: fromEmail, email: fromEmail,
}, },
to: [email.contact.email], to: [recipientEmail],
content: { content: {
subject: formattedEmail.subject, subject: formattedEmail.subject,
html: compiledHtml, html: compiledHtml,
}, },
reply: email.replyTo || undefined, reply: email.replyTo || undefined,
headers: customHeaders, headers: publicHeaders,
attachments: attachments, attachments: attachments,
tracking: shouldTrack, tracking: shouldTrack,
}); });
@@ -519,18 +519,25 @@ export class WorkflowExecutionService {
} }
/** /**
* SEND_EMAIL step - Send an email to the contact * SEND_EMAIL step - Send an email to the contact or a custom recipient
*/ */
private static async executeSendEmail( private static async executeSendEmail(
step: WorkflowStepWithTemplate, step: WorkflowStepWithTemplate,
execution: WorkflowExecutionWithRelations, execution: WorkflowExecutionWithRelations,
stepExecution: WorkflowStepExecution, stepExecution: WorkflowStepExecution,
_config: StepConfig, config: StepConfig,
): Promise<StepResult> { ): Promise<StepResult> {
if (!step.template) { if (!step.template) {
throw new Error('No template configured for SEND_EMAIL step'); throw new Error('No template configured for SEND_EMAIL step');
} }
// Parse step config to get recipient configuration
const stepConfig = config && typeof config === 'object' && !Array.isArray(config) ? config : {};
const recipientConfig =
stepConfig.recipient && typeof stepConfig.recipient === 'object' && !Array.isArray(stepConfig.recipient)
? (stepConfig.recipient as {type?: string; customEmail?: string})
: {type: 'CONTACT'};
// Get contact data for variable substitution // Get contact data for variable substitution
const contact = execution.contact; const contact = execution.contact;
const contactData = const contactData =
@@ -558,10 +565,20 @@ export class WorkflowExecutionService {
const renderedSubject = this.renderTemplate(step.template.subject, variables); const renderedSubject = this.renderTemplate(step.template.subject, variables);
const renderedBody = this.renderTemplate(step.template.body, variables); const renderedBody = this.renderTemplate(step.template.body, variables);
// Determine recipient email
let recipientEmail = contact.email;
let recipientContactId = contact.id;
if (recipientConfig.type === 'CUSTOM' && recipientConfig.customEmail) {
recipientEmail = recipientConfig.customEmail;
// For custom recipients, we don't associate with a contact
recipientContactId = contact.id; // Keep original contact for tracking
}
// Send email via EmailService // Send email via EmailService
const email = await EmailService.sendWorkflowEmail({ const email = await EmailService.sendWorkflowEmail({
projectId: execution.workflow.projectId, projectId: execution.workflow.projectId,
contactId: contact.id, contactId: recipientContactId,
workflowExecutionId: execution.id, workflowExecutionId: execution.id,
workflowStepExecutionId: stepExecution.id, // Use stepExecution.id, not step.id workflowStepExecutionId: stepExecution.id, // Use stepExecution.id, not step.id
templateId: step.template.id, templateId: step.template.id,
@@ -570,11 +587,15 @@ export class WorkflowExecutionService {
from: step.template.from, from: step.template.from,
fromName: step.template.fromName || undefined, fromName: step.template.fromName || undefined,
replyTo: step.template.replyTo || undefined, replyTo: step.template.replyTo || undefined,
// Pass custom recipient email if specified
recipientEmail: recipientConfig.type === 'CUSTOM' ? recipientEmail : undefined,
}); });
return { return {
emailId: email.id, emailId: email.id,
sentAt: email.createdAt, sentAt: email.createdAt,
recipientType: recipientConfig.type,
recipientEmail,
}; };
} }
File diff suppressed because it is too large Load Diff
+21
View File
@@ -208,6 +208,27 @@ export const WorkflowSchemas = {
}; };
export const WorkflowStepConfigSchemas = { export const WorkflowStepConfigSchemas = {
sendEmail: z.object({
templateId: uuid,
recipient: z
.object({
type: z.enum(['CONTACT', 'CUSTOM']),
customEmail: email.optional(),
})
.refine(
data => {
// If type is CUSTOM, customEmail must be provided
if (data.type === 'CUSTOM') {
return !!data.customEmail;
}
return true;
},
{
message: 'Custom email is required when recipient type is CUSTOM',
},
)
.optional(),
}),
delay: z delay: z
.object({ .object({
amount: z.number().positive(), amount: z.number().positive(),
+3
View File
@@ -26,3 +26,6 @@ export * from './segments/index.js';
// Security types // Security types
export * from './security/index.js'; export * from './security/index.js';
// Workflow types
export * from './workflows/index.js';
+33
View File
@@ -0,0 +1,33 @@
/**
* Workflow-specific type definitions
*/
/**
* Recipient type for workflow email steps
*/
export enum EmailRecipientType {
/** Send to the contact that triggered the workflow */
CONTACT = 'CONTACT',
/** Send to a custom email address */
CUSTOM = 'CUSTOM',
}
/**
* Configuration for email recipient in SEND_EMAIL workflow step
*/
export interface EmailRecipientConfig {
/** Type of recipient */
type: EmailRecipientType;
/** Custom email address (required when type is CUSTOM) */
customEmail?: string;
}
/**
* Configuration for SEND_EMAIL workflow step
*/
export interface SendEmailStepConfig {
/** Template ID to use for the email */
templateId: string;
/** Recipient configuration */
recipient?: EmailRecipientConfig;
}