feat(email): abandoned checkout email, 80% free tier warning, credits exhausted email (#3908)

* feat(email): send plain personal email on abandoned checkout

* feat(email): lower free tier warning to 80% and add credits exhausted email

* feat(email): use wordmark in email header instead of icon-only logo

* fix(email): restore accidentally deleted social icons in email footer

* fix(email): prevent double email for free users at 80%, fix subject line

* improvement(emails): extract shared plain email styles and proFeatures constant, fix double email on 100% usage

* fix(email): filter subscription-mode checkout, skip already-subscribed users, fix preview text

* fix(email): use notifications type for onboarding followup to respect unsubscribe preferences

* fix(email): use limit instead of currentUsage in credits exhausted email body

* fix(email): use notifications type for abandoned checkout, clarify crosses80 comment

* chore(email): rename _constants.ts to constants.ts

* fix(email): use isProPlan to catch org-level subscriptions in abandoned checkout guard

* fix(email): align onboarding followup delay to 5 days for email/password users
This commit is contained in:
Waleed
2026-04-02 19:31:29 -07:00
committed by GitHub
parent 6866da590c
commit ec51f73596
19 changed files with 305 additions and 42 deletions
+1 -1
View File
@@ -50,7 +50,7 @@ async function sendLifecycleEmail({ userId, type }: LifecycleEmailParams): Promi
html,
from,
replyTo,
emailType: 'transactional',
emailType: 'notifications',
})
logger.info('[lifecycle-email] Sent lifecycle email', { userId, type })
@@ -267,3 +267,24 @@ export const baseStyles = {
margin: '8px 0',
},
}
/** Styles for plain personal emails (no branding, no EmailLayout) */
export const plainEmailStyles = {
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
+1 -1
View File
@@ -1 +1 @@
export { baseStyles, colors, spacing, typography } from './base'
export { baseStyles, colors, plainEmailStyles, spacing, typography } from './base'
@@ -1,29 +1,10 @@
import { Body, Head, Html, Preview, Text } from '@react-email/components'
import { plainEmailStyles as styles } from '@/components/emails/_styles'
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>
@@ -0,0 +1,33 @@
import { Body, Head, Html, Preview, Text } from '@react-email/components'
import { plainEmailStyles as styles } from '@/components/emails/_styles'
interface AbandonedCheckoutEmailProps {
userName?: string
}
export function AbandonedCheckoutEmail({ userName }: AbandonedCheckoutEmailProps) {
return (
<Html>
<Head />
<Preview>Did you run into an issue with your upgrade?</Preview>
<Body style={styles.body}>
<div style={styles.container}>
<Text style={styles.p}>{userName ? `Hi ${userName},` : 'Hi,'}</Text>
<Text style={styles.p}>
I saw that you tried to upgrade your Sim plan but didn&apos;t end up completing it.
</Text>
<Text style={styles.p}>
Did you run into an issue, or did you have a question? Here to help.
</Text>
<Text style={styles.p}>
Emir
<br />
Founder, Sim
</Text>
</div>
</Body>
</Html>
)
}
export default AbandonedCheckoutEmail
@@ -0,0 +1,7 @@
/** Pro plan features shown in billing upgrade emails */
export const proFeatures = [
{ label: '6,000 credits/month', desc: 'included' },
{ label: '+50 daily refresh', desc: 'credits per day' },
{ label: '150 runs/min', desc: 'sync executions' },
{ label: '50GB storage', desc: 'for files & assets' },
] as const
@@ -0,0 +1,102 @@
import { Link, Section, Text } from '@react-email/components'
import { baseStyles, colors, typography } from '@/components/emails/_styles'
import { proFeatures } from '@/components/emails/billing/constants'
import { EmailLayout } from '@/components/emails/components'
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
import { getBrandConfig } from '@/ee/whitelabeling'
interface CreditsExhaustedEmailProps {
userName?: string
limit: number
upgradeLink: string
}
export function CreditsExhaustedEmail({
userName,
limit,
upgradeLink,
}: CreditsExhaustedEmailProps) {
const brand = getBrandConfig()
return (
<EmailLayout
preview={`You've used all ${dollarsToCredits(limit).toLocaleString()} of your free ${brand.name} credits`}
showUnsubscribe={true}
>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{userName ? `Hi ${userName},` : 'Hi,'}
</Text>
<Text style={baseStyles.paragraph}>
You&apos;ve used all <strong>{dollarsToCredits(limit).toLocaleString()}</strong> of your
free credits on {brand.name}. Your workflows are paused until you upgrade.
</Text>
<Section
style={{
backgroundColor: '#f8faf9',
border: `1px solid ${colors.brandTertiary}20`,
borderRadius: '8px',
padding: '16px 20px',
margin: '16px 0',
}}
>
<Text
style={{
fontSize: '14px',
fontWeight: 600,
color: colors.brandTertiary,
fontFamily: typography.fontFamily,
margin: '0 0 12px 0',
textTransform: 'uppercase' as const,
letterSpacing: '0.5px',
}}
>
Pro includes
</Text>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<tbody>
{proFeatures.map((feature, i) => (
<tr key={i}>
<td
style={{
padding: '6px 0',
fontSize: '15px',
fontWeight: 600,
color: colors.textPrimary,
fontFamily: typography.fontFamily,
width: '45%',
}}
>
{feature.label}
</td>
<td
style={{
padding: '6px 0',
fontSize: '14px',
color: colors.textMuted,
fontFamily: typography.fontFamily,
}}
>
{feature.desc}
</td>
</tr>
))}
</tbody>
</table>
</Section>
<Link href={upgradeLink} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Upgrade to Pro</Text>
</Link>
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
One-time notification when free credits are exhausted.
</Text>
</EmailLayout>
)
}
export default CreditsExhaustedEmail
@@ -1,5 +1,6 @@
import { Link, Section, Text } from '@react-email/components'
import { baseStyles, colors, typography } from '@/components/emails/_styles'
import { proFeatures } from '@/components/emails/billing/constants'
import { EmailLayout } from '@/components/emails/components'
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
import { getBrandConfig } from '@/ee/whitelabeling'
@@ -12,13 +13,6 @@ interface FreeTierUpgradeEmailProps {
upgradeLink: string
}
const proFeatures = [
{ label: '6,000 credits/month', desc: 'included' },
{ label: '+50 daily refresh', desc: 'credits per day' },
{ label: '150 runs/min', desc: 'sync executions' },
{ label: '50GB storage', desc: 'for files & assets' },
]
export function FreeTierUpgradeEmail({
userName,
percentUsed,
@@ -105,7 +99,7 @@ export function FreeTierUpgradeEmail({
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
One-time notification at 90% usage.
One-time notification at 80% usage.
</Text>
</EmailLayout>
)
@@ -1,4 +1,6 @@
export { AbandonedCheckoutEmail } from './abandoned-checkout-email'
export { CreditPurchaseEmail } from './credit-purchase-email'
export { CreditsExhaustedEmail } from './credits-exhausted-email'
export { EnterpriseSubscriptionEmail } from './enterprise-subscription-email'
export { FreeTierUpgradeEmail } from './free-tier-upgrade-email'
export { PaymentFailedEmail } from './payment-failed-email'
@@ -41,8 +41,9 @@ export function EmailLayout({
{/* Header with logo */}
<Section style={baseStyles.header}>
<Img
src={brand.logoUrl || `${baseUrl}/brand/color/email/type.png`}
width='70'
src={brand.logoUrl || `${baseUrl}/brand/color/email/wordmark.png`}
width='107'
height='33'
alt={brand.name}
style={{ display: 'block' }}
/>
+14
View File
@@ -6,7 +6,9 @@ import {
WelcomeEmail,
} from '@/components/emails/auth'
import {
AbandonedCheckoutEmail,
CreditPurchaseEmail,
CreditsExhaustedEmail,
EnterpriseSubscriptionEmail,
FreeTierUpgradeEmail,
PaymentFailedEmail,
@@ -168,6 +170,18 @@ export async function renderOnboardingFollowupEmail(userName?: string): Promise<
return await render(OnboardingFollowupEmail({ userName }))
}
export async function renderAbandonedCheckoutEmail(userName?: string): Promise<string> {
return await render(AbandonedCheckoutEmail({ userName }))
}
export async function renderCreditsExhaustedEmail(params: {
userName?: string
limit: number
upgradeLink: string
}): Promise<string> {
return await render(CreditsExhaustedEmail(params))
}
export async function renderCreditPurchaseEmail(params: {
userName?: string
amount: number
+7 -1
View File
@@ -16,6 +16,8 @@ export type EmailSubjectType =
| 'plan-welcome-pro'
| 'plan-welcome-team'
| 'credit-purchase'
| 'abandoned-checkout'
| 'free-tier-exhausted'
| 'onboarding-followup'
| 'welcome'
@@ -49,13 +51,17 @@ export function getEmailSubject(type: EmailSubjectType): string {
case 'usage-threshold':
return `You're nearing your monthly budget on ${brandName}`
case 'free-tier-upgrade':
return `You're at 90% of your free credits on ${brandName}`
return `You're at 80% of your free credits on ${brandName}`
case 'plan-welcome-pro':
return `Your Pro plan is now active on ${brandName}`
case 'plan-welcome-team':
return `Your Team plan is now active on ${brandName}`
case 'credit-purchase':
return `Credits added to your ${brandName} account`
case 'abandoned-checkout':
return `Quick question`
case 'free-tier-exhausted':
return `You've run out of free credits on ${brandName}`
case 'onboarding-followup':
return `Quick question about ${brandName}`
case 'welcome':
+6 -1
View File
@@ -47,6 +47,7 @@ import { isOrgPlan, isTeam } from '@/lib/billing/plan-helpers'
import { getPlans, resolvePlanFromStripeSubscription } from '@/lib/billing/plans'
import { hasPaidSubscriptionStatus } from '@/lib/billing/subscriptions/utils'
import { syncSeatsFromStripeQuantity } from '@/lib/billing/validation/seat-management'
import { handleAbandonedCheckout } from '@/lib/billing/webhooks/checkout'
import { handleChargeDispute, handleDisputeClosed } from '@/lib/billing/webhooks/disputes'
import { handleManualEnterpriseSubscription } from '@/lib/billing/webhooks/enterprise'
import {
@@ -615,7 +616,7 @@ export const auth = betterAuth({
await scheduleLifecycleEmail({
userId: user.id,
type: 'onboarding-followup',
delayDays: 3,
delayDays: 5,
})
} catch (error) {
logger.error(
@@ -2981,6 +2982,10 @@ export const auth = betterAuth({
await handleManualEnterpriseSubscription(event)
break
}
case 'checkout.session.expired': {
await handleAbandonedCheckout(event)
break
}
case 'charge.dispute.created': {
await handleChargeDispute(event)
break
+47 -8
View File
@@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger'
import { eq, inArray } from 'drizzle-orm'
import {
getEmailSubject,
renderCreditsExhaustedEmail,
renderFreeTierUpgradeEmail,
renderUsageThresholdEmail,
} from '@/components/emails'
@@ -714,16 +715,16 @@ export async function maybeSendUsageThresholdEmail(params: {
const baseUrl = getBaseUrl()
const isFreeUser = params.planName === 'Free'
// Check for 80% threshold (all users)
// Check for 80% threshold crossing — used for paid users (budget warning) and free users (upgrade nudge)
const crosses80 = params.percentBefore < 80 && params.percentAfter >= 80
// Check for 90% threshold (free users only)
const crosses90 = params.percentBefore < 90 && params.percentAfter >= 90
// Check for 100% threshold (free users only — credits exhausted)
const crosses100 = params.percentBefore < 100 && params.percentAfter >= 100
// Skip if no thresholds crossed
if (!crosses80 && !crosses90) return
if (!crosses80 && !crosses100) return
// For 80% threshold email (all users)
if (crosses80) {
// For 80% threshold email (paid users only)
if (crosses80 && !isFreeUser) {
const ctaLink = `${baseUrl}/workspace?billing=usage`
const sendTo = async (email: string, name?: string) => {
const prefs = await getEmailPreferences(email)
@@ -777,8 +778,8 @@ export async function maybeSendUsageThresholdEmail(params: {
}
}
// For 90% threshold email (free users only)
if (crosses90 && isFreeUser) {
// For 80% threshold email (free users only — skip if they also crossed 100% in same call)
if (crosses80 && isFreeUser && !crosses100) {
const upgradeLink = `${baseUrl}/workspace?billing=upgrade`
const sendFreeTierEmail = async (email: string, name?: string) => {
const prefs = await getEmailPreferences(email)
@@ -818,6 +819,44 @@ export async function maybeSendUsageThresholdEmail(params: {
await sendFreeTierEmail(params.userEmail, params.userName)
}
}
// For 100% threshold email (free users only — credits exhausted)
if (crosses100 && isFreeUser) {
const upgradeLink = `${baseUrl}/workspace?billing=upgrade`
const sendExhaustedEmail = async (email: string, name?: string) => {
const prefs = await getEmailPreferences(email)
if (prefs?.unsubscribeAll || prefs?.unsubscribeNotifications) return
const html = await renderCreditsExhaustedEmail({
userName: name,
limit: params.limit,
upgradeLink,
})
await sendEmail({
to: email,
subject: getEmailSubject('free-tier-exhausted'),
html,
emailType: 'notifications',
})
logger.info('Free tier credits exhausted email sent', {
email,
currentUsage: params.currentUsageAfter,
limit: params.limit,
})
}
if (params.scope === 'user' && params.userId && params.userEmail) {
const rows = await db
.select({ enabled: settings.billingUsageNotificationsEnabled })
.from(settings)
.where(eq(settings.userId, params.userId))
.limit(1)
if (rows.length > 0 && rows[0].enabled === false) return
await sendExhaustedEmail(params.userEmail, params.userName)
}
}
} catch (error) {
logger.error('Failed to send usage threshold email', {
scope: params.scope,
+58
View File
@@ -0,0 +1,58 @@
import { db } from '@sim/db'
import { user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { eq } from 'drizzle-orm'
import type Stripe from 'stripe'
import { getEmailSubject, renderAbandonedCheckoutEmail } from '@/components/emails'
import { isProPlan } from '@/lib/billing/core/subscription'
import { sendEmail } from '@/lib/messaging/email/mailer'
import { getPersonalEmailFrom } from '@/lib/messaging/email/utils'
const logger = createLogger('CheckoutWebhooks')
/**
* Handles checkout.session.expired — fires when a user starts an upgrade but doesn't complete it.
* Sends a plain personal email to check in and offer help.
* Only fires for subscription-mode sessions to avoid misfires on credit purchase or setup sessions.
* Skips users who have already completed a subscription (session may expire after a successful upgrade).
*/
export async function handleAbandonedCheckout(event: Stripe.Event): Promise<void> {
const session = event.data.object as Stripe.Checkout.Session
if (session.mode !== 'subscription') return
const customerId = typeof session.customer === 'string' ? session.customer : session.customer?.id
if (!customerId) {
logger.warn('No customer ID on expired session', { sessionId: session.id })
return
}
const [userData] = await db
.select({ id: user.id, email: user.email, name: user.name })
.from(user)
.where(eq(user.stripeCustomerId, customerId))
.limit(1)
if (!userData?.email) {
logger.warn('No user found for Stripe customer', { customerId, sessionId: session.id })
return
}
// Skip if the user already has a paid plan (direct or via org) — covers session expiring after a successful upgrade
const alreadySubscribed = await isProPlan(userData.id)
if (alreadySubscribed) return
const { from, replyTo } = getPersonalEmailFrom()
const html = await renderAbandonedCheckoutEmail(userData.name || undefined)
await sendEmail({
to: userData.email,
subject: getEmailSubject('abandoned-checkout'),
html,
from,
replyTo,
emailType: 'notifications',
})
logger.info('Sent abandoned checkout email', { userId: userData.id, sessionId: session.id })
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 394 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 407 B