feat(email): send onboarding followup email 3 days after signup (#3906)

* feat(email): send onboarding followup email 3 days after signup

* fix(email): add trigger guard, idempotency key, and shared task ID constant

* fix(email): increase onboarding followup delay from 3 to 5 days
This commit is contained in:
Waleed
2026-04-02 18:08:14 -07:00
committed by GitHub
parent 20c05644ab
commit b0c0ee29a8
7 changed files with 211 additions and 1 deletions

View File

@@ -0,0 +1,65 @@
import { db } from '@sim/db'
import { user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { task } from '@trigger.dev/sdk'
import { eq } from 'drizzle-orm'
import { getEmailSubject, renderOnboardingFollowupEmail } from '@/components/emails'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { checkEnterprisePlan } from '@/lib/billing/subscriptions/utils'
import { sendEmail } from '@/lib/messaging/email/mailer'
import { getPersonalEmailFrom } from '@/lib/messaging/email/utils'
import { LIFECYCLE_EMAIL_TASK_ID, type LifecycleEmailType } from '@/lib/messaging/lifecycle'
const logger = createLogger('LifecycleEmail')
interface LifecycleEmailParams {
userId: string
type: LifecycleEmailType
}
async function sendLifecycleEmail({ userId, type }: LifecycleEmailParams): Promise<void> {
const [userData] = await db.select().from(user).where(eq(user.id, userId)).limit(1)
if (!userData?.email) {
logger.warn('[lifecycle-email] User not found or has no email', { userId, type })
return
}
const subscription = await getHighestPrioritySubscription(userId)
if (checkEnterprisePlan(subscription)) {
logger.info('[lifecycle-email] Skipping lifecycle email for enterprise user', { userId, type })
return
}
const { from, replyTo } = getPersonalEmailFrom()
let html: string
switch (type) {
case 'onboarding-followup':
html = await renderOnboardingFollowupEmail(userData.name || undefined)
break
default:
logger.warn('[lifecycle-email] Unknown lifecycle email type', { type })
return
}
await sendEmail({
to: userData.email,
subject: getEmailSubject(type),
html,
from,
replyTo,
emailType: 'transactional',
})
logger.info('[lifecycle-email] Sent lifecycle email', { userId, type })
}
export const lifecycleEmailTask = task({
id: LIFECYCLE_EMAIL_TASK_ID,
retry: { maxAttempts: 2 },
run: async (params: LifecycleEmailParams) => {
await sendLifecycleEmail(params)
},
})

View File

@@ -1,3 +1,4 @@
export { OnboardingFollowupEmail } from './onboarding-followup-email'
export { OTPVerificationEmail } from './otp-verification-email'
export { ResetPasswordEmail } from './reset-password-email'
export { WelcomeEmail } from './welcome-email'

View File

@@ -0,0 +1,57 @@
import { Body, Head, Html, Preview, Text } from '@react-email/components'
interface OnboardingFollowupEmailProps {
userName?: string
}
const styles = {
body: {
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
backgroundColor: '#ffffff',
margin: '0',
padding: '0',
},
container: {
maxWidth: '560px',
margin: '40px auto',
padding: '0 24px',
},
p: {
fontSize: '15px',
lineHeight: '1.6',
color: '#1a1a1a',
margin: '0 0 16px',
},
} as const
export function OnboardingFollowupEmail({ userName }: OnboardingFollowupEmailProps) {
return (
<Html>
<Head />
<Preview>Quick question</Preview>
<Body style={styles.body}>
<div style={styles.container}>
<Text style={styles.p}>{userName ? `Hey ${userName},` : 'Hey,'}</Text>
<Text style={styles.p}>
It&apos;s been a few days since you signed up. I hope you&apos;re enjoying Sim!
</Text>
<Text style={styles.p}>
I&apos;d love to know what did you expect when you signed up vs. what did you get?
</Text>
<Text style={styles.p}>
A reply with your thoughts would really help us improve the product for everyone.
</Text>
<Text style={styles.p}>
Thanks,
<br />
Emir
<br />
Founder, Sim
</Text>
</div>
</Body>
</Html>
)
}
export default OnboardingFollowupEmail

View File

@@ -1,5 +1,10 @@
import { render } from '@react-email/components'
import { OTPVerificationEmail, ResetPasswordEmail, WelcomeEmail } from '@/components/emails/auth'
import {
OnboardingFollowupEmail,
OTPVerificationEmail,
ResetPasswordEmail,
WelcomeEmail,
} from '@/components/emails/auth'
import {
CreditPurchaseEmail,
EnterpriseSubscriptionEmail,
@@ -159,6 +164,10 @@ export async function renderWelcomeEmail(userName?: string): Promise<string> {
return await render(WelcomeEmail({ userName }))
}
export async function renderOnboardingFollowupEmail(userName?: string): Promise<string> {
return await render(OnboardingFollowupEmail({ userName }))
}
export async function renderCreditPurchaseEmail(params: {
userName?: string
amount: number

View File

@@ -16,6 +16,7 @@ export type EmailSubjectType =
| 'plan-welcome-pro'
| 'plan-welcome-team'
| 'credit-purchase'
| 'onboarding-followup'
| 'welcome'
/**
@@ -55,6 +56,8 @@ export function getEmailSubject(type: EmailSubjectType): string {
return `Your Team plan is now active on ${brandName}`
case 'credit-purchase':
return `Credits added to your ${brandName} account`
case 'onboarding-followup':
return `Quick question about ${brandName}`
case 'welcome':
return `Welcome to ${brandName}`
default:

View File

@@ -75,6 +75,7 @@ import { processCredentialDraft } from '@/lib/credentials/draft-processor'
import { sendEmail } from '@/lib/messaging/email/mailer'
import { getFromEmailAddress, getPersonalEmailFrom } from '@/lib/messaging/email/utils'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { scheduleLifecycleEmail } from '@/lib/messaging/lifecycle'
import { syncAllWebhooksForCredentialSet } from '@/lib/webhooks/utils.server'
import { SSO_TRUSTED_PROVIDERS } from '@/ee/sso/constants'
import { createAnonymousSession, ensureAnonymousUserExists } from './anonymous'
@@ -221,6 +222,19 @@ export const auth = betterAuth({
error,
})
}
try {
await scheduleLifecycleEmail({
userId: user.id,
type: 'onboarding-followup',
delayDays: 5,
})
} catch (error) {
logger.error(
'[databaseHooks.user.create.after] Failed to schedule onboarding followup email',
{ userId: user.id, error }
)
}
}
},
},
@@ -596,6 +610,19 @@ export const auth = betterAuth({
error,
})
}
try {
await scheduleLifecycleEmail({
userId: user.id,
type: 'onboarding-followup',
delayDays: 3,
})
} catch (error) {
logger.error(
'[emailVerification.onEmailVerification] Failed to schedule onboarding followup email',
{ userId: user.id, error }
)
}
}
},
},

View File

@@ -0,0 +1,48 @@
import { createLogger } from '@sim/logger'
import { tasks } from '@trigger.dev/sdk'
import { env } from '@/lib/core/config/env'
import { isTriggerDevEnabled } from '@/lib/core/config/feature-flags'
const logger = createLogger('LifecycleEmail')
export const LIFECYCLE_EMAIL_TASK_ID = 'lifecycle-email' as const
/** Supported lifecycle email types. Add new types here as the sequence grows. */
export type LifecycleEmailType = 'onboarding-followup'
interface ScheduleLifecycleEmailOptions {
userId: string
type: LifecycleEmailType
delayDays: number
}
/**
* Schedules a lifecycle email to be sent after a delay.
* Uses Trigger.dev's built-in delay scheduling — no polling or cron needed.
*/
export async function scheduleLifecycleEmail({
userId,
type,
delayDays,
}: ScheduleLifecycleEmailOptions): Promise<void> {
if (!isTriggerDevEnabled || !env.TRIGGER_SECRET_KEY) {
logger.info('[lifecycle] Trigger.dev not configured, skipping lifecycle email', {
userId,
type,
})
return
}
const delayUntil = new Date(Date.now() + delayDays * 24 * 60 * 60 * 1000)
await tasks.trigger(
LIFECYCLE_EMAIL_TASK_ID,
{ userId, type },
{
delay: delayUntil,
idempotencyKey: `lifecycle-${type}-${userId}`,
}
)
logger.info('[lifecycle] Scheduled lifecycle email', { userId, type, delayDays })
}