feat: Add support for custom email recipients in workflow steps
This commit is contained in:
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
@@ -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(),
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user