From 2d5476c6ef9020677d304a884968f9e5e14e6bf4 Mon Sep 17 00:00:00 2001 From: Joan Planas <114454447+joanproductivo@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:06:05 +0200 Subject: [PATCH 1/4] refactor(email): name the recipient override header The header that carries a workflow step's custom recipient was spelled out four times across the email service and the email processor. It is now one exported constant next to the other email header helpers, so the workflow engine can recognise these emails too. Co-Authored-By: Claude Opus 5.5 --- apps/api/src/jobs/email-processor.ts | 7 ++++--- apps/api/src/services/EmailHeaderService.ts | 7 +++++++ apps/api/src/services/EmailService.ts | 4 ++-- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/apps/api/src/jobs/email-processor.ts b/apps/api/src/jobs/email-processor.ts index fde2b5215..44511018c 100644 --- a/apps/api/src/jobs/email-processor.ts +++ b/apps/api/src/jobs/email-processor.ts @@ -28,6 +28,7 @@ import { bodyHasListManagementLink, buildEmailHeaders, classifyEmail, + RECIPIENT_OVERRIDE_HEADER, withSourceEmail, } from '../services/EmailHeaderService.js'; import {EmailService} from '../services/EmailService.js'; @@ -216,12 +217,12 @@ export async function createEmailWorker() { : undefined; // Check for custom recipient override in headers - const recipientEmail = customHeaders?.['X-Plunk-Recipient-Override'] || email.contact.email; + const recipientEmail = customHeaders?.[RECIPIENT_OVERRIDE_HEADER] || 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']; + if (publicHeaders && RECIPIENT_OVERRIDE_HEADER in publicHeaders) { + delete publicHeaders[RECIPIENT_OVERRIDE_HEADER]; } // Build the outbound headers: standards-based defaults for the email class diff --git a/apps/api/src/services/EmailHeaderService.ts b/apps/api/src/services/EmailHeaderService.ts index 9aacadb0e..7fc0cca7b 100644 --- a/apps/api/src/services/EmailHeaderService.ts +++ b/apps/api/src/services/EmailHeaderService.ts @@ -3,6 +3,13 @@ import {EmailSourceType} from '@plunk/db'; import {API_URI} from '../app/constants.js'; +/** + * Internal header on an email that a workflow step sends to a custom recipient instead of the + * contact. The email is still recorded under the contact; the email processor addresses it to this + * header's value and strips the header before sending. + */ +export const RECIPIENT_OVERRIDE_HEADER = 'X-Plunk-Recipient-Override'; + /** * Classification of an outbound email, derived by the caller from the email's * source type and template/campaign type. This is the single axis that decides diff --git a/apps/api/src/services/EmailService.ts b/apps/api/src/services/EmailService.ts index 9d466745b..d04138139 100644 --- a/apps/api/src/services/EmailService.ts +++ b/apps/api/src/services/EmailService.ts @@ -9,7 +9,7 @@ import {HttpException} from '../exceptions/index.js'; import {createTranslatorSync, renderTemplate} from '@plunk/shared'; import {BillingLimitService} from './BillingLimitService.js'; -import {withSourceEmail} from './EmailHeaderService.js'; +import {RECIPIENT_OVERRIDE_HEADER, withSourceEmail} from './EmailHeaderService.js'; import {QueueService} from './QueueService.js'; interface Attachment { @@ -251,7 +251,7 @@ export class EmailService { // 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; + emailHeaders[RECIPIENT_OVERRIDE_HEADER] = params.recipientEmail; } const email = await prisma.email.create({ From 482085f0a601dc474061716e20e8bd334143b673 Mon Sep 17 00:00:00 2001 From: Joan Planas <114454447+joanproductivo@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:08:28 +0200 Subject: [PATCH 2/4] feat(workflows): let email waits count only the execution's own emails A WAIT_FOR_EVENT step resumes when the event is tracked on the contact, so a wait for email.open after a SEND_EMAIL resumed on an open of any email: a newsletter, a receipt, or a notification another step sent to a custom recipient, which is recorded under the contact. The step config takes an optional scope. With scope "execution", the email events a workflow email produces (email.sent, email.delivery, email.open, email.click, email.bounce, email.complaint) only count when the email was sent to the contact by the waiting execution. Without a scope, or for any other event, waits behave as before. trackEvent now hands its email id to handleEvent, and the email is looked up once per event by primary key, only when a scoped wait needs it. Co-Authored-By: Claude Opus 5.5 --- apps/api/src/services/EventService.ts | 2 +- .../src/services/WorkflowExecutionService.ts | 42 ++++- ...WorkflowExecutionService.waitScope.test.ts | 143 ++++++++++++++++++ apps/mcp/src/tools/workflow-graph.ts | 1 + .../src/__tests__/workflow-events.test.ts | 19 +++ packages/shared/src/index.ts | 1 + packages/shared/src/schemas/index.ts | 2 + packages/shared/src/workflow-events.ts | 17 +++ 8 files changed, 225 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/services/__tests__/WorkflowExecutionService.waitScope.test.ts create mode 100644 packages/shared/src/__tests__/workflow-events.test.ts create mode 100644 packages/shared/src/workflow-events.ts diff --git a/apps/api/src/services/EventService.ts b/apps/api/src/services/EventService.ts index 0a06b15d6..34f1fa5cf 100644 --- a/apps/api/src/services/EventService.ts +++ b/apps/api/src/services/EventService.ts @@ -41,7 +41,7 @@ export class EventService { await this.triggerWorkflows(projectId, eventName, contactId, data); // Resume workflows waiting for this event - await WorkflowExecutionService.handleEvent(projectId, eventName, contactId, data); + await WorkflowExecutionService.handleEvent(projectId, eventName, contactId, data, emailId); return event; } diff --git a/apps/api/src/services/WorkflowExecutionService.ts b/apps/api/src/services/WorkflowExecutionService.ts index 8afb3b0f7..81301e5c3 100644 --- a/apps/api/src/services/WorkflowExecutionService.ts +++ b/apps/api/src/services/WorkflowExecutionService.ts @@ -1,5 +1,6 @@ import type { Contact, + Email, Prisma, Template, Workflow, @@ -9,7 +10,7 @@ import type { } from '@plunk/db'; import {StepExecutionStatus, WorkflowExecutionStatus} from '@plunk/db'; import {toPrismaJson} from '@plunk/types'; -import {renderTemplate, WorkflowStepConfigSchemas} from '@plunk/shared'; +import {canScopeWaitToExecution, renderTemplate, WorkflowStepConfigSchemas} from '@plunk/shared'; import dns from 'node:dns/promises'; import net from 'node:net'; import signale from 'signale'; @@ -18,6 +19,7 @@ import {prisma} from '../database/prisma.js'; import {HttpException} from '../exceptions/index.js'; import {DASHBOARD_URI} from '../app/constants.js'; +import {RECIPIENT_OVERRIDE_HEADER} from './EmailHeaderService.js'; import {EmailService} from './EmailService.js'; import {NtfyService} from './NtfyService.js'; import {QueueService} from './QueueService.js'; @@ -403,6 +405,7 @@ export class WorkflowExecutionService { eventName: string, contactId?: string, data?: Record, + emailId?: string, ): Promise { // Find workflows waiting for this event const waitingExecutions = await prisma.workflowStepExecution.findMany({ @@ -434,10 +437,29 @@ export class WorkflowExecutionService { }, }); + // The email behind the event, loaded at most once and only for a wait scoped to its execution + let sourceEmail: Pick | null | undefined; + for (const stepExecution of waitingExecutions) { const config = stepExecution.step.config; if (config && typeof config === 'object' && 'eventName' in config && config.eventName === eventName) { + // A wait scoped to its execution counts only the emails that execution sent to the contact + if ('scope' in config && config.scope === 'execution' && canScopeWaitToExecution(eventName)) { + if (sourceEmail === undefined) { + sourceEmail = emailId + ? await prisma.email.findUnique({ + where: {id: emailId}, + select: {workflowExecutionId: true, headers: true}, + }) + : null; + } + + if (!this.sentByExecution(sourceEmail, stepExecution.executionId)) { + continue; + } + } + // Event matches, resume execution await prisma.workflowStepExecution.update({ where: {id: stepExecution.id}, @@ -461,6 +483,24 @@ export class WorkflowExecutionService { } } + /** + * Whether an email was sent to the contact by the given execution. An email a step sent to a + * custom recipient is recorded under the contact too, but it is not the contact's engagement. + */ + private static sentByExecution( + email: Pick | null, + executionId: string, + ): boolean { + if (email?.workflowExecutionId !== executionId) { + return false; + } + + const headers = + email.headers && typeof email.headers === 'object' && !Array.isArray(email.headers) ? email.headers : {}; + + return !(RECIPIENT_OVERRIDE_HEADER in headers); + } + /** * Execute a specific step based on its type */ diff --git a/apps/api/src/services/__tests__/WorkflowExecutionService.waitScope.test.ts b/apps/api/src/services/__tests__/WorkflowExecutionService.waitScope.test.ts new file mode 100644 index 000000000..5713b18fa --- /dev/null +++ b/apps/api/src/services/__tests__/WorkflowExecutionService.waitScope.test.ts @@ -0,0 +1,143 @@ +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import {EmailSourceType, EmailStatus, StepExecutionStatus, WorkflowExecutionStatus, WorkflowStepType} from '@plunk/db'; +import {prisma as servicePrisma} from '../../database/prisma'; +import {EventService} from '../EventService'; +import {WorkflowExecutionService} from '../WorkflowExecutionService'; +import {factories, getPrismaClient} from '../../../../../test/helpers'; + +vi.mock('../QueueService', () => ({ + QueueService: { + queueWorkflowStep: vi.fn(async () => ({id: 'mock-job-id'})), + queueEmail: vi.fn(async () => ({id: 'mock-email-job-id'})), + queueWorkflowTimeout: vi.fn(async () => ({id: 'mock-timeout-job-id'})), + cancelWorkflowTimeout: vi.fn(async () => true), + }, +})); + +/** + * By default a WAIT_FOR_EVENT step resumes on the event from any email of the contact. + * `scope: "execution"` limits email events to the emails the waiting execution sent the contact. + */ +describe('WorkflowExecutionService - WAIT_FOR_EVENT scope', () => { + const prisma = getPrismaClient(); + let projectId: string; + let contactId: string; + + beforeEach(async () => { + const {project} = await factories.createUserWithProject(); + projectId = project.id; + contactId = (await factories.createContact({projectId, subscribed: true})).id; + }); + + /** TRIGGER → SEND_EMAIL → WAIT_FOR_EVENT → EXIT, run until it waits. */ + async function runUntilWaiting(waitConfig: Record, sendConfig: Record = {}) { + const template = await factories.createTemplate({projectId}); + const workflow = await factories.createWorkflow({projectId}); + const trigger = await prisma.workflowStep.findFirstOrThrow({ + where: {workflowId: workflow.id, type: WorkflowStepType.TRIGGER}, + }); + const send = await factories.createWorkflowStep({ + workflowId: workflow.id, + type: WorkflowStepType.SEND_EMAIL, + templateId: template.id, + config: {templateId: template.id, ...sendConfig}, + }); + const wait = await factories.createWorkflowStep({ + workflowId: workflow.id, + type: WorkflowStepType.WAIT_FOR_EVENT, + config: {timeout: 3600, ...waitConfig}, + }); + const exit = await factories.createWorkflowStep({workflowId: workflow.id, type: WorkflowStepType.EXIT}); + await prisma.workflowTransition.createMany({ + data: [ + {fromStepId: trigger.id, toStepId: send.id}, + {fromStepId: send.id, toStepId: wait.id}, + {fromStepId: wait.id, toStepId: exit.id}, + ], + }); + + const execution = await prisma.workflowExecution.create({ + data: {workflowId: workflow.id, contactId, status: WorkflowExecutionStatus.RUNNING, currentStepId: trigger.id}, + }); + await WorkflowExecutionService.processStepExecution(execution.id, trigger.id); + + const sent = await prisma.email.findFirstOrThrow({where: {workflowExecutionId: execution.id}}); + const waitStatus = async () => + (await prisma.workflowStepExecution.findFirstOrThrow({where: {executionId: execution.id, stepId: wait.id}})) + .status; + expect(await waitStatus()).toBe(StepExecutionStatus.WAITING); + + return {sent, waitStatus}; + } + + const newsletter = () => + factories.createEmail({ + projectId, + contactId, + sourceType: EmailSourceType.CAMPAIGN, + status: EmailStatus.SENT, + sentAt: new Date(), + }); + + /** The call the SES webhook makes for an open (Webhooks.receiveSNSWebhook). */ + const open = (emailId: string) => EventService.trackEvent(projectId, 'email.open', contactId, emailId, {emailId}); + + it('without a scope, resumes on an open of any email of the contact, and looks no email up', async () => { + const run = await runUntilWaiting({eventName: 'email.open'}); + const emailLookup = vi.spyOn(servicePrisma.email, 'findUnique'); + + await open((await newsletter()).id); + + expect(await run.waitStatus()).toBe(StepExecutionStatus.COMPLETED); + expect(emailLookup).not.toHaveBeenCalled(); + }); + + it("with scope execution, ignores other emails and resumes on the execution's own email", async () => { + const run = await runUntilWaiting({eventName: 'email.open', scope: 'execution'}); + const emailLookup = vi.spyOn(servicePrisma.email, 'findUnique'); + + await open((await newsletter()).id); + expect(await run.waitStatus()).toBe(StepExecutionStatus.WAITING); + expect(emailLookup).toHaveBeenCalledTimes(1); + + await open(run.sent.id); + expect(await run.waitStatus()).toBe(StepExecutionStatus.COMPLETED); + }); + + it('with scope execution, ignores an email the execution sent to a custom recipient', async () => { + const run = await runUntilWaiting( + {eventName: 'email.open', scope: 'execution'}, + {recipient: {type: 'CUSTOM', customEmail: 'team@plunk.test'}}, + ); + + await open(run.sent.id); + + expect(await run.waitStatus()).toBe(StepExecutionStatus.WAITING); + }); + + it('with scope execution, ignores an email event that names no email', async () => { + const run = await runUntilWaiting({eventName: 'email.open', scope: 'execution'}); + + await EventService.trackEvent(projectId, 'email.open', contactId); + + expect(await run.waitStatus()).toBe(StepExecutionStatus.WAITING); + }); + + it('with scope execution, resumes only the execution that sent the email', async () => { + const first = await runUntilWaiting({eventName: 'email.click', scope: 'execution'}); + const second = await runUntilWaiting({eventName: 'email.click', scope: 'execution'}); + + await EventService.trackEvent(projectId, 'email.click', contactId, second.sent.id, {emailId: second.sent.id}); + + expect(await first.waitStatus()).toBe(StepExecutionStatus.WAITING); + expect(await second.waitStatus()).toBe(StepExecutionStatus.COMPLETED); + }); + + it('applies no scope to events an execution cannot have produced', async () => { + const run = await runUntilWaiting({eventName: 'order.shipped', scope: 'execution'}); + + await EventService.trackEvent(projectId, 'order.shipped', contactId); + + expect(await run.waitStatus()).toBe(StepExecutionStatus.COMPLETED); + }); +}); diff --git a/apps/mcp/src/tools/workflow-graph.ts b/apps/mcp/src/tools/workflow-graph.ts index ebfaf951c..48407ae66 100644 --- a/apps/mcp/src/tools/workflow-graph.ts +++ b/apps/mcp/src/tools/workflow-graph.ts @@ -111,6 +111,7 @@ const STEP_CONFIG_SCHEMAS: Record = { .object({amount: z.number().positive(), unit: z.enum(DELAY_UNITS)}) .refine((data) => data.amount <= MAX_DELAY[data.unit], {message: 'Delay cannot exceed 365 days'}), WAIT_FOR_EVENT: z.object({ + scope: z.enum(['contact', 'execution']).optional(), eventName: z.string().min(1), timeout: z.number().positive().max(MAX_WAIT_SECONDS, 'Timeout cannot exceed 365 days').optional(), }), diff --git a/packages/shared/src/__tests__/workflow-events.test.ts b/packages/shared/src/__tests__/workflow-events.test.ts new file mode 100644 index 000000000..d9db34e09 --- /dev/null +++ b/packages/shared/src/__tests__/workflow-events.test.ts @@ -0,0 +1,19 @@ +import {describe, expect, it} from 'vitest'; + +import {canScopeWaitToExecution} from '../workflow-events.js'; + +describe('canScopeWaitToExecution', () => { + it.each(['email.sent', 'email.delivery', 'email.open', 'email.click', 'email.bounce', 'email.complaint'])( + 'accepts %s, which a workflow email produces', + eventName => { + expect(canScopeWaitToExecution(eventName)).toBe(true); + }, + ); + + it.each(['email.received', 'email.opened', 'contact.subscribed', 'order.shipped', ''])( + 'refuses %s, which cannot be traced back to an execution', + eventName => { + expect(canScopeWaitToExecution(eventName)).toBe(false); + }, + ); +}); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 668b6828c..8d6d09c39 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -4,3 +4,4 @@ export * from './template/index.js'; export * from './i18n/index.js'; export * from './mailbox-simulator.js'; export * from './unsubscribe.js'; +export * from './workflow-events.js'; diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts index 5334220a1..6b4ccc499 100644 --- a/packages/shared/src/schemas/index.ts +++ b/packages/shared/src/schemas/index.ts @@ -341,6 +341,8 @@ export const WorkflowStepConfigSchemas = { }, ), waitForEvent: z.object({ + // "execution": count only emails this execution sent, for the events canScopeWaitToExecution accepts + scope: z.enum(['contact', 'execution']).optional(), eventName: z.string().min(1), timeout: z.number().positive().max(31536000, 'Timeout cannot exceed 365 days (31,536,000 seconds)').optional(), }), diff --git a/packages/shared/src/workflow-events.ts b/packages/shared/src/workflow-events.ts new file mode 100644 index 000000000..0bfb64554 --- /dev/null +++ b/packages/shared/src/workflow-events.ts @@ -0,0 +1,17 @@ +/** + * Email events a workflow's own sends produce. A WAIT_FOR_EVENT step waiting for one of these can + * count only the emails its execution sent (`scope: "execution"`). Other events cannot be traced + * back to an execution, so the scope does not apply to them. + */ +const EXECUTION_SCOPED_EVENTS = new Set([ + 'email.sent', + 'email.delivery', + 'email.open', + 'email.click', + 'email.bounce', + 'email.complaint', +]); + +export function canScopeWaitToExecution(eventName: string): boolean { + return EXECUTION_SCOPED_EVENTS.has(eventName); +} From 0a52d1577bac19a54c0d68eb3aaaac8665d3575c Mon Sep 17 00:00:00 2001 From: Joan Planas <114454447+joanproductivo@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:09:48 +0200 Subject: [PATCH 3/4] feat(web): offer the execution scope in the wait dialog For a wait on an email event a workflow email produces, the step dialog shows "Only emails from this execution", which saves scope "execution". The workflows concept page explains the default, the scope, and that emails sent to a custom recipient never count for a scoped wait. Co-Authored-By: Claude Opus 5.5 --- .../workflow-steps/WaitForEventStepDialog.tsx | 37 ++++++++++++++++++- apps/wiki/content/docs/concepts/workflows.mdx | 2 + 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/workflow-steps/WaitForEventStepDialog.tsx b/apps/web/src/components/workflow-steps/WaitForEventStepDialog.tsx index 8bbfa0e20..b57aa32af 100644 --- a/apps/web/src/components/workflow-steps/WaitForEventStepDialog.tsx +++ b/apps/web/src/components/workflow-steps/WaitForEventStepDialog.tsx @@ -1,4 +1,18 @@ -import {Command, CommandGroup, CommandItem, CommandList, Input, Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@plunk/ui'; +import {canScopeWaitToExecution} from '@plunk/shared'; +import { + Command, + CommandGroup, + CommandItem, + CommandList, + Input, + Label, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Switch, +} from '@plunk/ui'; import {useState} from 'react'; import {toast} from 'sonner'; import useSWR from 'swr'; @@ -48,6 +62,7 @@ export function WaitForEventStepDialog({step, workflowId, open, onOpenChange, on const [eventPopoverOpen, setEventPopoverOpen] = useState(false); const [timeoutAmount, setTimeoutAmount] = useState(initialTimeout.amount); const [timeoutUnit, setTimeoutUnit] = useState(initialUnit); + const [onlyThisExecution, setOnlyThisExecution] = useState(config.scope === 'execution'); const {data: eventNamesData} = useSWR<{eventNames: string[]}>(open ? '/events/names' : null, { revalidateOnFocus: false, @@ -71,9 +86,12 @@ export function WaitForEventStepDialog({step, workflowId, open, onOpenChange, on const timeoutSeconds = amount > 0 ? amount * SECONDS_PER_UNIT[timeoutUnit] : 0; + // The scope only applies to email events an execution's own emails produce + const scoped = onlyThisExecution && canScopeWaitToExecution(eventName); + const ok = await update({ name, - config: {eventName, timeout: timeoutSeconds}, + config: {eventName, timeout: timeoutSeconds, ...(scoped ? {scope: 'execution'} : {})}, }); if (ok) { @@ -155,6 +173,21 @@ export function WaitForEventStepDialog({step, workflowId, open, onOpenChange, on + {canScopeWaitToExecution(eventName) && ( +
+ +
+ +

+ Count the event only for emails this workflow sent to the contact in this execution. Otherwise any email + the contact receives counts. Set a timeout too, in case the email is never sent or opened. +

+
+
+ )} +
diff --git a/apps/wiki/content/docs/concepts/workflows.mdx b/apps/wiki/content/docs/concepts/workflows.mdx index 8ed1321e3..4b4b86a8d 100644 --- a/apps/wiki/content/docs/concepts/workflows.mdx +++ b/apps/wiki/content/docs/concepts/workflows.mdx @@ -41,6 +41,8 @@ A workflow always begins with a single auto-created `TRIGGER` step. You build th | `UPDATE_CONTACT` | Patches contact data — useful for tagging contacts as they progress (`{ stage: "activated" }`). | `data` object | | `EXIT` | Terminates the execution. Optionally records an `exitReason` for analytics. | optional `reason` | +A `WAIT_FOR_EVENT` step on an email event resumes when that event comes from any email of the contact, including emails sent by campaigns or other workflows (`scope: "contact"`, the default). For the events a workflow email produces (`email.sent`, `email.delivery`, `email.open`, `email.click`, `email.bounce`, `email.complaint`), set `scope: "execution"` in the step config (**Only emails from this execution** in the editor) to count only the emails this execution sent to the contact. Emails a step sends to a custom recipient are recorded under the contact but never count for a scoped wait. If the execution sent several emails, an event from any of them resumes the wait. Give a scoped wait a timeout: if its email is never sent (for example, to an unsubscribed contact) or never opened, the execution would otherwise wait forever. + ## Transitions Transitions are the edges of the workflow graph. For most step types a transition is a simple "next" pointer. For `CONDITION` steps, each transition carries a `branch` discriminator (`"yes"` or `"no"`) so the engine knows which path to take when the condition evaluates. From c630c6ce6504e4c8e6d1c5c5ba9ad7e594ca3745 Mon Sep 17 00:00:00 2001 From: Dries Augustyns Date: Thu, 29 Oct 2026 17:40:39 +0100 Subject: [PATCH 4/4] feat(web): require a timeout for a scoped wait, and call it a run A wait that counts only its own run's emails never resumes if that email is never sent or opened, so the dialog now refuses to save one without a timeout. "Execution" is our internal term; the editor says "run". Co-Authored-By: Claude Opus 5.5 --- .../workflow-steps/WaitForEventStepDialog.tsx | 12 +++++++++--- apps/wiki/content/docs/concepts/workflows.mdx | 2 +- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/workflow-steps/WaitForEventStepDialog.tsx b/apps/web/src/components/workflow-steps/WaitForEventStepDialog.tsx index 55d1d90ad..fce08cb38 100644 --- a/apps/web/src/components/workflow-steps/WaitForEventStepDialog.tsx +++ b/apps/web/src/components/workflow-steps/WaitForEventStepDialog.tsx @@ -90,6 +90,12 @@ export function WaitForEventStepDialog({step, workflowId, open, onOpenChange, on // The scope only applies to email events an execution's own emails produce const scoped = onlyThisExecution && canScopeWaitToExecution(eventName); + // A scoped wait whose email is never sent or opened would otherwise wait forever + if (scoped && timeoutSeconds === 0) { + toast.error('Set a timeout when waiting only for emails from this run'); + return; + } + const ok = await update({ name, config: {eventName, timeout: timeoutSeconds, ...(scoped ? {scope: 'execution'} : {})}, @@ -179,11 +185,11 @@ export function WaitForEventStepDialog({step, workflowId, open, onOpenChange, on

- Count the event only for emails this workflow sent to the contact in this execution. Otherwise any email - the contact receives counts. Set a timeout too, in case the email is never sent or opened. + Count the event only for emails this workflow sent to the contact in this run. Otherwise any email the + contact receives counts. Requires a timeout, in case the email is never sent or opened.

diff --git a/apps/wiki/content/docs/concepts/workflows.mdx b/apps/wiki/content/docs/concepts/workflows.mdx index d181beef3..75ab6be58 100644 --- a/apps/wiki/content/docs/concepts/workflows.mdx +++ b/apps/wiki/content/docs/concepts/workflows.mdx @@ -66,7 +66,7 @@ A workflow always begins with a single auto-created `TRIGGER` step. You build th | `UPDATE_CONTACT` | Patches contact data — useful for tagging contacts as they progress (`{ stage: "activated" }`). | `data` object | | `EXIT` | Terminates the execution. Optionally records an `exitReason` for analytics. | optional `reason` | -A `WAIT_FOR_EVENT` step on an email event resumes when that event comes from any email of the contact, including emails sent by campaigns or other workflows (`scope: "contact"`, the default). For the events a workflow email produces (`email.sent`, `email.delivery`, `email.open`, `email.click`, `email.bounce`, `email.complaint`), set `scope: "execution"` in the step config (**Only emails from this execution** in the editor) to count only the emails this execution sent to the contact. Emails a step sends to a custom recipient are recorded under the contact but never count for a scoped wait. If the execution sent several emails, an event from any of them resumes the wait. Give a scoped wait a timeout: if its email is never sent (for example, to an unsubscribed contact) or never opened, the execution would otherwise wait forever. +A `WAIT_FOR_EVENT` step on an email event resumes when that event comes from any email of the contact, including emails sent by campaigns or other workflows (`scope: "contact"`, the default). For the events a workflow email produces (`email.sent`, `email.delivery`, `email.open`, `email.click`, `email.bounce`, `email.complaint`), set `scope: "execution"` in the step config (**Only emails from this run** in the editor) to count only the emails this execution sent to the contact. Emails a step sends to a custom recipient are recorded under the contact but never count for a scoped wait. If the execution sent several emails, an event from any of them resumes the wait. The editor requires a timeout for a scoped wait: if its email is never sent (for example, to an unsubscribed contact) or never opened, the execution would otherwise wait forever. ## Transitions