mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
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:
65
apps/sim/background/lifecycle-email.ts
Normal file
65
apps/sim/background/lifecycle-email.ts
Normal 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)
|
||||
},
|
||||
})
|
||||
@@ -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'
|
||||
|
||||
@@ -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's been a few days since you signed up. I hope you're enjoying Sim!
|
||||
</Text>
|
||||
<Text style={styles.p}>
|
||||
I'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
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
48
apps/sim/lib/messaging/lifecycle.ts
Normal file
48
apps/sim/lib/messaging/lifecycle.ts
Normal 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 })
|
||||
}
|
||||
Reference in New Issue
Block a user