# Welcome Email

Customer onboarding guides users through required setup, improves their product understanding, and helps them gain value faster. Sending a brief welcome email after signup with a helpful link to get started is common practice.

Users are busy people who often contend with changing priorities, so sometimes they disengage before onboarding is complete. To mitigate onboarding drop-off, you may want to send a follow-up email only to users who have not reached a milestone in time. What you need is a workflow that waits for an event that may or may not arrive, with a timeout triggering the appropriate fallback behavior. Let's build a small durable workflow in Hatchet that handles exactly that pattern:

```mermaid
flowchart TD
    A[user:signup event] --> B[Send welcome email]
    B --> C[Wait for onboarding or timeout]
    C --> D[user:onboarding-completed]
    C --> E[Timeout fires]
    D --> F[Skip follow-up]
    E --> G[Send follow-up email]
```

Hatchet's durable execution keeps the workflow alive across the wait. If the worker restarts or the wait lasts days, the workflow picks up where it left off.

## Setup


### Prepare your environment

To run this example you need:

- a working local Hatchet environment or access to [Hatchet Cloud](https://cloud.onhatchet.run)
- a Hatchet SDK example environment (see the [Quickstart](/v1/quickstart))

No external email provider is required. The example uses `print` / `console.log` in place of real email delivery.

### Define the models

Start by defining the input and output types. The workflow receives the new user's email and ID, and returns which emails were sent.

#### Python

```python
class SignupInput(BaseModel):
    email: str
    user_id: str


class WelcomeEmailResult(BaseModel):
    user_id: str
    welcome_sent: bool
    follow_up_sent: bool
```

#### Typescript

```typescript
export type SignupInput = {
  email: string;
  user_id: string;
};

export type WelcomeEmailResult = {
  userId: string;
  welcomeSent: boolean;
  followUpSent: boolean;
};
```

### Build the durable task

The core of this example is a single [durable task](/v1/durable-execution) that runs three steps in sequence:

1. Send the welcome email immediately.
2. Wait for either a `user:onboarding-completed` event scoped to this user, or a timeout.
3. If the timeout fires, send a follow-up. If the onboarding event arrives first, skip it.

#### Python

```python
@hatchet.durable_task(
    name="welcome-email",
    on_events=["user:signup"],
    input_validator=SignupInput,
    execution_timeout=timedelta(minutes=5),
)
async def welcome_email(input: SignupInput, ctx: DurableContext) -> WelcomeEmailResult:
    # Step 1: Send the welcome email
    print(f"Sending welcome email to {input.email}: finish your first onboarding step")

    # Step 2: Wait for the user to complete onboarding, or time out
    # (use a longer duration for a more realistic workflow)
    now = await ctx.aio_now()
    consider_events_since = now - timedelta(minutes=LOOKBACK_MINUTES)

    wait_result = await ctx.aio_wait_for(
        "onboarding-or-timeout",
        or_(
            SleepCondition(timedelta(seconds=TIMEOUT_SECONDS)),
            # Scope the event condition to this user so that another user's
            # onboarding-completed event does not resolve this wait.
            UserEventCondition(
                event_key=ONBOARDING_EVENT_KEY,
                scope=input.user_id,
                consider_events_since=consider_events_since,
            ),
        ),
    )

    # The or-group result is {"CREATE": {"<condition_key>": ...}}.
    # Check whether the onboarding event was the one that resolved.
    resolved_key = list(wait_result["CREATE"].keys())[0]
    onboarding_completed = resolved_key == ONBOARDING_EVENT_KEY

    if onboarding_completed:
        # Step 3a: User completed onboarding -> skip follow-up
        print(f"User {input.user_id} completed onboarding, skipping follow-up")
        return WelcomeEmailResult(
            user_id=input.user_id,
            welcome_sent=True,
            follow_up_sent=False,
        )

    # Step 3b: Timeout -> send follow-up email
    print(f"Sending follow-up email to {input.email}: need help finishing onboarding?")
    return WelcomeEmailResult(
        user_id=input.user_id,
        welcome_sent=True,
        follow_up_sent=True,
    )
```

#### Typescript

```typescript
export const welcomeEmail = hatchet.durableTask({
  name: 'welcome-email',
  onEvents: ['user:signup'],
  executionTimeout: '5m',
  fn: async (input, ctx) => {
    // Step 1: Send the welcome email
    console.log(`Sending welcome email to ${input.email}: finish your first onboarding step`);

    // Step 2: Wait for the user to complete onboarding, or time out
    // (use a longer duration for a more realistic workflow)
    const now = await ctx.now();
    const considerEventsSince = new Date(
      now.getTime() - durationToMs(LOOKBACK_WINDOW)
    ).toISOString();

    const waitResult = await ctx.waitFor(
      Or(
        { sleepFor: `${TIMEOUT_SECONDS}s` },
        // Scope the event condition to this user so that another user's
        // onboarding-completed event does not resolve this wait.
        { eventKey: ONBOARDING_EVENT_KEY, scope: input.user_id, considerEventsSince }
      )
    );

    // The or-group result is { CREATE: { <condition_key>: ... } }.
    // Check whether the onboarding event was the one that resolved.
    const create = (waitResult as Record<string, Record<string, unknown>>)['CREATE'] ?? waitResult;
    const resolvedKey = Object.keys(create as Record<string, unknown>)[0] ?? '';
    const onboardingCompleted = resolvedKey === ONBOARDING_EVENT_KEY;

    if (onboardingCompleted) {
      // Step 3a: User completed onboarding -> skip follow-up
      console.log(`User ${input.user_id} completed onboarding, skipping follow-up`);
      return { userId: input.user_id, welcomeSent: true, followUpSent: false };
    }

    // Step 3b: Timeout -> send follow-up email
    console.log(`Sending follow-up email to ${input.email}: need help finishing onboarding?`);
    return { userId: input.user_id, welcomeSent: true, followUpSent: true };
  },
});
```

In this example, `user:onboarding-completed` represents an activation event from your application. Your app would emit it when the user finishes the onboarding milestone. The welcome email points the user toward that step; the follow-up is only for users who do not complete it before the timeout. The timeout itself is durable, so the workflow does not need to keep a worker process sleeping while it waits, and it can resume even if the worker restarts during the wait.

The event condition uses a `scope` set to the current user's ID. When the `user:onboarding-completed` event is later pushed, it must include the same `scope` value. Scoping by user ID ensures that one user's onboarding event cannot satisfy another user's wait condition. The condition also includes a short lookback window. This lets the workflow pick up an onboarding event that arrived slightly before the wait became active. This can happen when the welcome email step and the onboarding event occur nearly at the same time.

### Register and start the worker

Register the durable task on a Hatchet worker and start it.

#### Python

```python
def main() -> None:
    worker = hatchet.worker("welcome-email-worker", workflows=[welcome_email])
    worker.start()


if __name__ == "__main__":
    main()
```

#### Typescript

In TypeScript, workflows are registered through the shared example worker
rather than a per-example registration file.

### Trigger the workflow

The durable task is configured to start from a `user:signup` event. The event payload is passed through as-is to the task input:

```json
{
  "email": "alice@example.com",
  "user_id": "user-123"
}
```

The example also includes a trigger script that starts the workflow directly, pushes a scoped onboarding event, and waits for the result.

#### Python

```python
from examples.welcome_email.worker import (
    ONBOARDING_EVENT_KEY,
    SignupInput,
    hatchet,
    welcome_email,
)

signup = SignupInput(
    email="alice@example.com",
    user_id="user-123",
)

# Start the welcome-email workflow
ref = welcome_email.run(signup, wait_for_result=False)
print(f"Started workflow run: {ref.workflow_run_id}")

# Push onboarding-completed event (scoped to this user)
print("Pushing onboarding-completed event...")
hatchet.event.push(
    ONBOARDING_EVENT_KEY,
    {"status": "done"},
    scope=signup.user_id,
)

# Wait for the workflow to complete
result = ref.result()
print(f"Workflow completed: {result}")
```

#### Typescript

```typescript
import { hatchet } from '../hatchet-client';
import { welcomeEmail, ONBOARDING_EVENT_KEY, SignupInput } from './workflow';

async function main() {
  const input: SignupInput = {
    email: 'alice@example.com',
    user_id: 'user-123',
  };

  // Start the welcome-email workflow
  const ref = await welcomeEmail.runNoWait(input);
  const runId = await ref.getWorkflowRunId();
  console.log(`Started workflow run: ${runId}`);

  // Push onboarding-completed event (scoped to this user)
  console.log('Pushing onboarding-completed event...');
  await hatchet.events.push(ONBOARDING_EVENT_KEY, { status: 'done' }, { scope: input.user_id });

  // Wait for the workflow to complete
  const result = await ref.output;
  console.log('Workflow completed:', result);
}
```

### Test it

This example includes two end-to-end tests against a live Hatchet instance:

- an onboarding-completed test, where the scoped event arrives before the timeout and the follow-up is skipped
- a timeout test, where no onboarding event arrives and the workflow sends the follow-up

If you are running the SDK examples locally:

#### Python

```bash
    pytest examples/welcome_email/test_welcome_email.py
```

#### Typescript

```bash
    pnpm run test:e2e -- --testPathPattern=welcome_email
```


## Next steps

To send real emails from this example, replace the `print` / `console.log` calls with a call to your email provider. You may also want to extend the timeout to better suit your workflow. Provider-specific delivery concerns are intentionally outside this minimal example.
