Email sequences
Send a personalized onboarding sequence over several days.
Use Runlane to send one onboarding email at a time and release the run between stages. Your application owns recipients, consent, templates, engagement data, suppression lists, and sequence progress.
Send the next onboarding email
The Runlane task, durable step, and release below are real. The marked state, email, and persistence calls are pseudocode for your application and email provider:
import { durationStringSchema, task } from '@runlane/core'
import * as z from 'zod'
const emailSchema = z.object({
data: z.record(z.string(), z.string()),
delayAfterSend: durationStringSchema.optional(),
id: z.string(),
template: z.enum(['feature-spotlight', 'power-tips', 'setup-help', 'welcome']),
to: z.email(),
})
const sequenceStateSchema = z.discriminatedUnion('completed', [
z.object({ completed: z.literal(true), emailsSent: z.number().int().nonnegative() }),
z.object({ completed: z.literal(false), email: emailSchema, emailsSent: z.number().int().nonnegative() }),
])
const sendResultSchema = z.object({ messageId: z.string() })
const sequenceResultSchema = z.object({ completed: z.literal(true), emailsSent: z.number().int().nonnegative() })
export const onboardingSequence = task({
id: 'email.onboarding-sequence',
schema: z.object({ userId: z.string() }),
output: sequenceResultSchema,
async run({ userId }, context) {
//////////////////////////////////
// MAKE YOUR API CALLS HERE
//////////////////////////////////
const state = sequenceStateSchema.parse(await loadNextOnboardingEmail({ signal: context.signal, userId }))
if (state.completed) return state
const delivery = await context.step.run(
`send-${state.email.id}`,
{ output: sendResultSchema },
async ({ token }) => {
//////////////////////////////////
// MAKE YOUR API CALLS HERE
//////////////////////////////////
const result = await sendEmail({ email: state.email, idempotencyKey: token, signal: context.signal })
return sendResultSchema.parse(result)
},
)
//////////////////////////////////
// MAKE YOUR API CALLS HERE
//////////////////////////////////
await recordOnboardingEmail({
emailId: state.email.id,
messageId: delivery.messageId,
signal: context.signal,
userId,
})
if (state.email.delayAfterSend !== undefined) {
return context.release(state.email.delayAfterSend, { reason: 'next_onboarding_email' })
}
return { completed: true, emailsSent: state.emailsSent + 1 }
},
})Application state selects a concrete template such as welcome, setup-help, power-tips, or feature-spotlight. It can use current engagement data to choose the next message.
The task starts from the top after every release or retry. It reads the next unfinished email each time instead of retaining sequence progress in local variables. The send step uses one stable key per email and passes its token to the provider as an idempotency key.
recordOnboardingEmail() sits outside the completed step and may run again. Implement it as an idempotent update keyed by the email and provider message ids.
Decide how to wake the next stage
Use context.release() for a fixed delay. For a calendar date or customer-controlled schedule, store the due time in your application and trigger from an application scheduler. Do not leave a task function sleeping.
Re-check consent and suppression state when loading every stage. A recipient can unsubscribe while the run is released.
Before production
- Give every email a stable id.
- Use provider idempotency for every message.
- Keep content and recipient lists outside Runlane.
- Bound email concurrency to provider limits.
- Test retries before and after recording a delivery.
- Test unsubscribe changes during a release.
Read wait without failing and save step results.