From 78d3d224af60cf5b58c6b18fdd0921320912fb09 Mon Sep 17 00:00:00 2001 From: Dries Augustyns Date: Tue, 17 Feb 2026 18:57:15 +0100 Subject: [PATCH] feat: Add support for custom email recipients in workflow steps --- apps/api/src/jobs/email-processor.ts | 20 +- apps/api/src/services/EmailService.ts | 25 +- .../src/services/WorkflowExecutionService.ts | 27 +- apps/web/src/pages/workflows/[id].tsx | 1881 ++++++++++------- packages/shared/src/schemas/index.ts | 21 + packages/types/src/index.ts | 3 + packages/types/src/workflows/index.ts | 33 + 7 files changed, 1274 insertions(+), 736 deletions(-) create mode 100644 packages/types/src/workflows/index.ts diff --git a/apps/api/src/jobs/email-processor.ts b/apps/api/src/jobs/email-processor.ts index 22af89a..8436fd2 100644 --- a/apps/api/src/jobs/email-processor.ts +++ b/apps/api/src/jobs/email-processor.ts @@ -117,10 +117,25 @@ export async function createEmailWorker() { const fromName = email.fromName || email.project.name; 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) + : 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 const recipient: {name?: string; email: string} | string = email.toName - ? {name: email.toName, email: email.contact.email} - : email.contact.email; + ? {name: email.toName, email: recipientEmail} + : recipientEmail; // Determine tracking based on project settings and email type const shouldTrack = EmailService.shouldTrackEmail(email.project.tracking, email.sourceType); @@ -137,6 +152,7 @@ export async function createEmailWorker() { html: compiledHtml, }, reply: email.replyTo || undefined, + headers: publicHeaders, tracking: shouldTrack, attachments: email.attachments as {filename: string; content: string; contentType: string}[] | null, }); diff --git a/apps/api/src/services/EmailService.ts b/apps/api/src/services/EmailService.ts index 3ecc28b..ef63f24 100644 --- a/apps/api/src/services/EmailService.ts +++ b/apps/api/src/services/EmailService.ts @@ -35,6 +35,7 @@ interface SendEmailParams { campaignId?: string; workflowExecutionId?: 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 // 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({ where: {id: params.contactId}, select: {subscribed: true}, @@ -242,6 +244,12 @@ export class EmailService { 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({ data: { projectId: params.projectId, @@ -251,7 +259,7 @@ export class EmailService { from: params.from, fromName: params.fromName, 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, sourceType, templateId: params.templateId, @@ -361,6 +369,15 @@ export class EmailService { ? (email.headers as Record) : 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 const attachments = email.attachments && Array.isArray(email.attachments) @@ -376,13 +393,13 @@ export class EmailService { name: fromName, email: fromEmail, }, - to: [email.contact.email], + to: [recipientEmail], content: { subject: formattedEmail.subject, html: compiledHtml, }, reply: email.replyTo || undefined, - headers: customHeaders, + headers: publicHeaders, attachments: attachments, tracking: shouldTrack, }); diff --git a/apps/api/src/services/WorkflowExecutionService.ts b/apps/api/src/services/WorkflowExecutionService.ts index 1b90b6c..b0011a8 100644 --- a/apps/api/src/services/WorkflowExecutionService.ts +++ b/apps/api/src/services/WorkflowExecutionService.ts @@ -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( step: WorkflowStepWithTemplate, execution: WorkflowExecutionWithRelations, stepExecution: WorkflowStepExecution, - _config: StepConfig, + config: StepConfig, ): Promise { if (!step.template) { 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 const contact = execution.contact; const contactData = @@ -558,10 +565,20 @@ export class WorkflowExecutionService { const renderedSubject = this.renderTemplate(step.template.subject, 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 const email = await EmailService.sendWorkflowEmail({ projectId: execution.workflow.projectId, - contactId: contact.id, + contactId: recipientContactId, workflowExecutionId: execution.id, workflowStepExecutionId: stepExecution.id, // Use stepExecution.id, not step.id templateId: step.template.id, @@ -570,11 +587,15 @@ export class WorkflowExecutionService { from: step.template.from, fromName: step.template.fromName || undefined, replyTo: step.template.replyTo || undefined, + // Pass custom recipient email if specified + recipientEmail: recipientConfig.type === 'CUSTOM' ? recipientEmail : undefined, }); return { emailId: email.id, sentAt: email.createdAt, + recipientType: recipientConfig.type, + recipientEmail, }; } diff --git a/apps/web/src/pages/workflows/[id].tsx b/apps/web/src/pages/workflows/[id].tsx index ea47cd9..2ebd5a7 100644 --- a/apps/web/src/pages/workflows/[id].tsx +++ b/apps/web/src/pages/workflows/[id].tsx @@ -826,6 +826,8 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo // SEND_EMAIL fields const [templateId, setTemplateId] = useState(''); + const [recipientType, setRecipientType] = useState<'CONTACT' | 'CUSTOM'>('CONTACT'); + const [customEmail, setCustomEmail] = useState(''); // DELAY fields const [delayAmount, setDelayAmount] = useState('24'); @@ -958,7 +960,29 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo setIsSubmitting(false); return; } - config = {templateId}; + + // Validate custom email if recipient type is CUSTOM + if (recipientType === 'CUSTOM') { + if (!customEmail || !customEmail.trim()) { + toast.error('Please enter a custom email address'); + setIsSubmitting(false); + return; + } + // Basic email validation + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(customEmail)) { + toast.error('Please enter a valid email address'); + setIsSubmitting(false); + return; + } + } + + config = { + templateId, + recipient: { + type: recipientType, + ...(recipientType === 'CUSTOM' && {customEmail: customEmail.trim()}), + }, + }; } else if (type === 'DELAY') { const amount = parseInt(delayAmount); // Validate max 365 days @@ -1071,6 +1095,8 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo setName(''); setType('SEND_EMAIL'); setTemplateId(''); + setRecipientType('CONTACT'); + setCustomEmail(''); setDelayAmount('24'); setDelayUnit('hours'); setConditionField(''); @@ -1100,93 +1126,178 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo Add Workflow Step +

Configure a new step to add to your workflow

-
-
- - -

- Note: The trigger step is automatically created with every workflow -

-
+ + {/* Basic Information Section */} +
+
+
+

Basic Information

+
-
- - setName(e.target.value)} - required - placeholder="e.g., Send Welcome Email" - /> +
+ + +

Choose the type of action this step will perform

+
+ +
+ + setName(e.target.value)} + required + placeholder="e.g., Send Welcome Email" + className="mt-1.5" + /> +

+ A descriptive name to identify this step in the workflow +

+
{/* SEND_EMAIL Configuration */} {type === 'SEND_EMAIL' && ( -
- - + + + + + {templatesData?.data.map(template => ( + + ))} + + +

The email template to use for this step

+
+ +
+ + +

Choose who should receive this email

+
+ + {recipientType === 'CUSTOM' && ( +
+ + setCustomEmail(e.target.value)} + required + placeholder="e.g., admin@example.com" + className="mt-1.5" /> - ))} - - +

+ The specific email address that will receive this email +

+
+ )} +
)} {/* DELAY Configuration */} {type === 'DELAY' && (
-
+
+
+

Delay Configuration

+
+ +
- +
- +
+

Maximum delay: 365 days

)} {/* CONDITION Configuration */} {type === 'CONDITION' && ( -
-

Configure the condition to evaluate

- -
- - {loadingFields ? ( -
- - - - - Loading fields... -
- ) : availableFields.length > 0 ? ( - <> - -

- Select from {availableFields.length} field{availableFields.length !== 1 ? 's' : ''} in your - contacts -

- - ) : ( - <> - setConditionField(e.target.value)} - required - placeholder="e.g., contact.subscribed or contact.data.plan" - /> -

- No fields found in contacts. Enter a field manually. -

- - )} +
+
+
+

Condition Configuration

+

+ Define the condition that determines which path contacts will follow +

-
- - - {currentFieldType && ( -

- Showing operators for{' '} - {currentFieldType} fields -

- )} -
- - {needsValue && ( +
- - {currentFieldType === 'boolean' ? ( - - ) : currentFieldType === 'number' ? ( - setConditionValue(e.target.value)} - required - placeholder="e.g., 100" - /> - ) : currentFieldType === 'date' ? ( - setConditionValue(e.target.value)} - required - /> + + {loadingFields ? ( +
+ + + + + Loading fields... +
+ ) : availableFields.length > 0 ? ( + <> + +

+ {availableFields.length} field{availableFields.length !== 1 ? 's' : ''} available from your + contacts +

+ ) : ( - setConditionValue(e.target.value)} - required - placeholder="e.g., premium, active" - /> + <> + setConditionField(e.target.value)} + required + placeholder="e.g., contact.subscribed or contact.data.plan" + className="mt-1.5" + /> +

Enter a field path (e.g., contact.data.plan)

+ )}
- )} + +
+ + + {currentFieldType && ( +

+ Operators for{' '} + {currentFieldType} type + fields +

+ )} +
+ + {needsValue && ( +
+ + {currentFieldType === 'boolean' ? ( + + ) : currentFieldType === 'number' ? ( + setConditionValue(e.target.value)} + required + placeholder="e.g., 100" + className="mt-1.5" + /> + ) : currentFieldType === 'date' ? ( + setConditionValue(e.target.value)} + required + className="mt-1.5" + /> + ) : ( + setConditionValue(e.target.value)} + required + placeholder="e.g., premium, active" + className="mt-1.5" + /> + )} +

The value to compare against

+
+ )} +
)} {/* WAIT_FOR_EVENT Configuration */} {type === 'WAIT_FOR_EVENT' && (
-
- - {eventNamesData?.eventNames && eventNamesData.eventNames.length > 0 ? ( - - ) : ( - setEventName(e.target.value)} - required - placeholder="e.g., email.clicked, user.upgraded" - /> - )} -

- {eventNamesData?.eventNames && eventNamesData.eventNames.length > 0 - ? 'Select from previously tracked events' - : 'The event name to wait for'} -

+
+
+

Wait for Event Configuration

-
- -
- setEventTimeoutAmount(e.target.value)} - placeholder="1" - min="0" - max={ - eventTimeoutUnit === 'minutes' - ? 525600 - : eventTimeoutUnit === 'hours' - ? 8760 - : eventTimeoutUnit === 'days' - ? 365 - : undefined - } - className="flex-1" - /> - +
+
+ + {eventNamesData?.eventNames && eventNamesData.eventNames.length > 0 ? ( + + ) : ( + setEventName(e.target.value)} + required + placeholder="e.g., email.clicked, user.upgraded" + className="mt-1.5" + /> + )} +

+ {eventNamesData?.eventNames && eventNamesData.eventNames.length > 0 + ? 'The workflow will pause until this event occurs' + : 'Enter the event name to wait for'} +

+
+ +
+ +
+ setEventTimeoutAmount(e.target.value)} + placeholder="1" + min="0" + max={ + eventTimeoutUnit === 'minutes' + ? 525600 + : eventTimeoutUnit === 'hours' + ? 8760 + : eventTimeoutUnit === 'days' + ? 365 + : undefined + } + className="flex-1" + /> + +
+

+ Continue the workflow after this time even if the event hasn't occurred +

@@ -1446,85 +1594,125 @@ function AddStepDialog({open, onOpenChange, workflowId, onSuccess}: AddStepDialo {/* WEBHOOK Configuration */} {type === 'WEBHOOK' && (
-
- - setWebhookUrl(e.target.value)} - required - placeholder="https://api.example.com/webhook" - /> +
+
+

Webhook Configuration

-
- - -
+
+
+ + setWebhookUrl(e.target.value)} + required + placeholder="https://api.example.com/webhook" + className="mt-1.5" + /> +

The endpoint that will receive the webhook request

+
-
- -