Existing Implementation: The boilerplate includes a welcome follow-up email that sends 24 hours after user registration.
Welcome Follow-Up Email
The existing implementation sends a follow-up email 24 hours after user registration.Function Definition
Located insrc/lib/inngest/functions.ts:
const sendWelcomeFollowUpEmail = inngest.createFunction(
{id: 'send-welcome-follow-up-email'},
{event: 'user/registered'},
async ({event, step}) => {
// Wait 24 hours after registration
await step.sleep('wait-24-hours', '24h')
// Get user data
const userData = await step.run('get-user-data', async () => {
return {
id: event.data.userId,
name: event.data.userName,
email: event.data.userEmail,
language: event.data.language || 'fr',
}
})
// Send follow-up email
await step.run('send-follow-up-email', async () => {
const appUrl = env.NEXT_PUBLIC_APP_URL
return await sendWelcomeFollowUpEmailService({
email: userData.email,
userName: userData.name,
appUrl,
language: userData.language as SupportedLanguage,
})
})
return {
success: true,
userId: userData.id,
emailSent: true,
sentAt: new Date().toISOString(),
}
}
)Triggering the Workflow
The event is triggered from the user service during registration:// In src/services/user-service.ts
await triggerInngestWelcomeFollowUpEmail({
userId: newUser.id,
userName: newUser.name,
userEmail: newUser.email,
language: locale as SupportedLanguage,
})Creating Custom Email Workflows
1. Multi-Step Onboarding Sequence
export const onboardingSequence = inngest.createFunction(
{id: 'onboarding-sequence'},
{event: 'user/registered'},
async ({event, step}) => {
const {userId, userName, userEmail, language} = event.data
// Day 1: Welcome email (immediate)
await step.run('send-welcome-email', async () => {
return await sendWelcomeEmailService({
email: userEmail,
userName,
language,
})
})
// Day 3: Getting started tips
await step.sleep('wait-3-days', '3d')
await step.run('send-getting-started-tips', async () => {
return await sendGettingStartedEmailService({
email: userEmail,
userName,
language,
})
})
// Day 7: Feature highlights
await step.sleep('wait-4-more-days', '4d')
await step.run('send-feature-highlights', async () => {
return await sendFeatureHighlightsEmailService({
email: userEmail,
userName,
language,
})
})
return {success: true, sequence: 'completed'}
}
)2. Abandoned Cart Recovery
export const cartRecoverySequence = inngest.createFunction(
{id: 'cart-recovery-sequence'},
{event: 'cart/abandoned'},
async ({event, step}) => {
const {userId, cartId, userEmail, items} = event.data
// Wait 1 hour
await step.sleep('wait-1-hour', '1h')
// Check if cart is still abandoned
const cartStatus = await step.run('check-cart-status', async () => {
return await getCartStatusService(cartId)
})
if (cartStatus.purchased) {
return {abandoned: false, message: 'Cart was completed'}
}
// Send first reminder
await step.run('send-first-reminder', async () => {
return await sendCartReminderEmailService({
email: userEmail,
cartId,
items,
reminderNumber: 1,
})
})
// Wait 24 hours
await step.sleep('wait-24-hours', '24h')
// Check again
const finalCartStatus = await step.run('check-final-cart-status', async () => {
return await getCartStatusService(cartId)
})
if (!finalCartStatus.purchased) {
// Send final reminder with discount
await step.run('send-discount-reminder', async () => {
return await sendCartDiscountEmailService({
email: userEmail,
cartId,
discountCode: 'SAVE15',
})
})
}
return {success: true, finalStatus: finalCartStatus}
}
)3. Trial Expiration Workflow
export const trialExpirationWorkflow = inngest.createFunction(
{id: 'trial-expiration-workflow'},
{event: 'trial/started'},
async ({event, step}) => {
const {userId, trialEndsAt, userEmail, userName} = event.data
// Send trial started confirmation
await step.run('send-trial-started', async () => {
return await sendTrialStartedEmailService({
email: userEmail,
userName,
trialEndsAt,
})
})
// Calculate days until expiration
const trialDuration = new Date(trialEndsAt).getTime() - Date.now()
const daysUntilExpiry = Math.floor(trialDuration / (1000 * 60 * 60 * 24))
// Send 7-day warning (if trial is longer than 7 days)
if (daysUntilExpiry > 7) {
await step.sleep('wait-until-7-days-left', `${daysUntilExpiry - 7}d`)
await step.run('send-7-day-warning', async () => {
return await sendTrialWarningEmailService({
email: userEmail,
userName,
daysLeft: 7,
})
})
}
// Send 3-day warning
await step.sleep('wait-until-3-days-left', '4d') // 7-3=4 days to wait
await step.run('send-3-day-warning', async () => {
return await sendTrialWarningEmailService({
email: userEmail,
userName,
daysLeft: 3,
})
})
// Send final day warning
await step.sleep('wait-until-final-day', '2d')
await step.run('send-final-day-warning', async () => {
return await sendTrialFinalWarningEmailService({
email: userEmail,
userName,
})
})
return {success: true, workflow: 'completed'}
}
)Email Workflow Patterns
Conditional Logic
export const conditionalEmailWorkflow = inngest.createFunction(
{id: 'conditional-email-workflow'},
{event: 'user/action'},
async ({event, step}) => {
const userActivity = await step.run('check-user-activity', async () => {
return await getUserActivityService(event.data.userId)
})
if (userActivity.isActive) {
// Send engagement email
await step.run('send-engagement-email', async () => {
return await sendEngagementEmailService({
email: event.data.userEmail,
activityData: userActivity,
})
})
} else {
// Send reactivation sequence
await step.run('send-reactivation-email', async () => {
return await sendReactivationEmailService({
email: event.data.userEmail,
lastActive: userActivity.lastActiveAt,
})
})
// Follow up in 7 days if still inactive
await step.sleep('wait-7-days', '7d')
await step.run('send-final-reactivation', async () => {
return await sendFinalReactivationEmailService({
email: event.data.userEmail,
})
})
}
return {workflow: 'completed'}
}
)Error Handling
export const robustEmailWorkflow = inngest.createFunction(
{
id: 'robust-email-workflow',
retries: 3,
},
{event: 'email/sequence'},
async ({event, step}) => {
try {
await step.run('send-email-with-retry', async () => {
// This step will retry on failure
return await sendEmailService(event.data)
})
} catch (error) {
// Log error and continue with alternative action
await step.run('handle-email-failure', async () => {
await logEmailFailure(event.data.userEmail, error)
// Maybe add to a manual review queue
return await addToManualEmailQueue(event.data)
})
}
return {processed: true}
}
)Testing Email Workflows
Development Testing
-
Start Inngest Dev Server:
pnpm dlx inngest-cli@latest dev -
Manual Trigger: Use the dashboard at
http://localhost:8288to test functions with sample data:{ "name": "user/registered", "data": { "userId": "test_123", "userName": "Test User", "userEmail": "test@example.com", "language": "en" } }
Workflow Monitoring
The Inngest dashboard shows:- Step Progress: See which steps completed successfully
- Sleep States: Monitor delayed executions
- Error Details: Debug failed steps
- Retry Attempts: Track automatic retries
Best Practices
1
Use Descriptive Step Names
Name each step clearly for debugging:
await step.run('send-welcome-email-with-onboarding-tips', async () => {
return await sendWelcomeEmail(userData)
})2
Handle Failures Gracefully
Plan for email service outages:
await step.run('send-email-with-fallback', async () => {
try {
return await primaryEmailService.send(emailData)
} catch (error) {
return await fallbackEmailService.send(emailData)
}
})3
Use Environment-Specific Delays
Shorter delays in development:
const delay = process.env.NODE_ENV === 'development' ? '30s' : '24h'
await step.sleep('wait-for-follow-up', delay)Email workflows are now configured! Your automated email sequences will handle user engagement, onboarding, and retention automatically.