From ffec9e94eeaaf84d0b80c70bd88dff3a4254d1c7 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 2 Apr 2026 18:20:22 -0700 Subject: [PATCH 01/13] feat(email): send plain personal email on abandoned checkout --- .../billing/abandoned-checkout-email.tsx | 52 +++++++++++++++++ apps/sim/components/emails/billing/index.ts | 1 + apps/sim/components/emails/render.ts | 5 ++ apps/sim/components/emails/subjects.ts | 3 + apps/sim/lib/auth/auth.ts | 5 ++ apps/sim/lib/billing/webhooks/checkout.ts | 57 +++++++++++++++++++ 6 files changed, 123 insertions(+) create mode 100644 apps/sim/components/emails/billing/abandoned-checkout-email.tsx create mode 100644 apps/sim/lib/billing/webhooks/checkout.ts diff --git a/apps/sim/components/emails/billing/abandoned-checkout-email.tsx b/apps/sim/components/emails/billing/abandoned-checkout-email.tsx new file mode 100644 index 00000000000..969b3257f2d --- /dev/null +++ b/apps/sim/components/emails/billing/abandoned-checkout-email.tsx @@ -0,0 +1,52 @@ +import { Body, Head, Html, Preview, Text } from '@react-email/components' + +interface AbandonedCheckoutEmailProps { + 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 AbandonedCheckoutEmail({ userName }: AbandonedCheckoutEmailProps) { + return ( + + + Quick question + +
+ {userName ? `Hi ${userName},` : 'Hi,'} + + I saw that you tried to upgrade your Sim plan but didn't end up completing it. + + + Did you run into an issue, or did you have a question? Here to help. + + + — Emir +
+ Founder, Sim +
+
+ + + ) +} + +export default AbandonedCheckoutEmail diff --git a/apps/sim/components/emails/billing/index.ts b/apps/sim/components/emails/billing/index.ts index 81f25ebfbd6..4624a2dc451 100644 --- a/apps/sim/components/emails/billing/index.ts +++ b/apps/sim/components/emails/billing/index.ts @@ -1,3 +1,4 @@ +export { AbandonedCheckoutEmail } from './abandoned-checkout-email' export { CreditPurchaseEmail } from './credit-purchase-email' export { EnterpriseSubscriptionEmail } from './enterprise-subscription-email' export { FreeTierUpgradeEmail } from './free-tier-upgrade-email' diff --git a/apps/sim/components/emails/render.ts b/apps/sim/components/emails/render.ts index da20e6df8da..021529af3dd 100644 --- a/apps/sim/components/emails/render.ts +++ b/apps/sim/components/emails/render.ts @@ -6,6 +6,7 @@ import { WelcomeEmail, } from '@/components/emails/auth' import { + AbandonedCheckoutEmail, CreditPurchaseEmail, EnterpriseSubscriptionEmail, FreeTierUpgradeEmail, @@ -168,6 +169,10 @@ export async function renderOnboardingFollowupEmail(userName?: string): Promise< return await render(OnboardingFollowupEmail({ userName })) } +export async function renderAbandonedCheckoutEmail(userName?: string): Promise { + return await render(AbandonedCheckoutEmail({ userName })) +} + export async function renderCreditPurchaseEmail(params: { userName?: string amount: number diff --git a/apps/sim/components/emails/subjects.ts b/apps/sim/components/emails/subjects.ts index a84add32d86..c1d53633cfa 100644 --- a/apps/sim/components/emails/subjects.ts +++ b/apps/sim/components/emails/subjects.ts @@ -16,6 +16,7 @@ export type EmailSubjectType = | 'plan-welcome-pro' | 'plan-welcome-team' | 'credit-purchase' + | 'abandoned-checkout' | 'onboarding-followup' | 'welcome' @@ -56,6 +57,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 'abandoned-checkout': + return `Quick question` case 'onboarding-followup': return `Quick question about ${brandName}` case 'welcome': diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 26f8f6fe0f9..93ff7f39605 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -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 { @@ -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 diff --git a/apps/sim/lib/billing/webhooks/checkout.ts b/apps/sim/lib/billing/webhooks/checkout.ts new file mode 100644 index 00000000000..9f71a98e53a --- /dev/null +++ b/apps/sim/lib/billing/webhooks/checkout.ts @@ -0,0 +1,57 @@ +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 { 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. + */ +export async function handleAbandonedCheckout(event: Stripe.Event): Promise { + const session = event.data.object as Stripe.Checkout.Session + + const customerId = typeof session.customer === 'string' ? session.customer : session.customer?.id + if (!customerId) { + logger.warn('[handleAbandonedCheckout] 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('[handleAbandonedCheckout] No user found for Stripe customer', { + customerId, + sessionId: session.id, + }) + 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: 'transactional', + }) + + logger.info('[handleAbandonedCheckout] Sent abandoned checkout email', { + userId: userData.id, + sessionId: session.id, + }) +} From ab465ee464a4f3786ac173550d57533468245b81 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 2 Apr 2026 18:28:56 -0700 Subject: [PATCH 02/13] feat(email): lower free tier warning to 80% and add credits exhausted email --- .../billing/credits-exhausted-email.tsx | 110 ++++++++++++++++++ .../billing/free-tier-upgrade-email.tsx | 2 +- apps/sim/components/emails/billing/index.ts | 1 + apps/sim/components/emails/render.ts | 10 ++ apps/sim/components/emails/subjects.ts | 3 + apps/sim/lib/billing/core/usage.ts | 52 ++++++++- 6 files changed, 172 insertions(+), 6 deletions(-) create mode 100644 apps/sim/components/emails/billing/credits-exhausted-email.tsx diff --git a/apps/sim/components/emails/billing/credits-exhausted-email.tsx b/apps/sim/components/emails/billing/credits-exhausted-email.tsx new file mode 100644 index 00000000000..5fd36a82848 --- /dev/null +++ b/apps/sim/components/emails/billing/credits-exhausted-email.tsx @@ -0,0 +1,110 @@ +import { Link, Section, Text } from '@react-email/components' +import { baseStyles, colors, typography } from '@/components/emails/_styles' +import { EmailLayout } from '@/components/emails/components' +import { dollarsToCredits } from '@/lib/billing/credits/conversion' +import { getBrandConfig } from '@/ee/whitelabeling' + +interface CreditsExhaustedEmailProps { + userName?: string + currentUsage: number + limit: number + 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 CreditsExhaustedEmail({ + userName, + currentUsage, + limit, + upgradeLink, +}: CreditsExhaustedEmailProps) { + const brand = getBrandConfig() + + return ( + + + {userName ? `Hi ${userName},` : 'Hi,'} + + + + You've used all {dollarsToCredits(currentUsage).toLocaleString()} of + your free credits on {brand.name}. Your workflows are paused until you upgrade. + + +
+ + Pro includes + + + + {proFeatures.map((feature, i) => ( + + + + + ))} + +
+ {feature.label} + + {feature.desc} +
+
+ + + Upgrade to Pro + + +
+ + + One-time notification when free credits are exhausted. + + + ) +} + +export default CreditsExhaustedEmail diff --git a/apps/sim/components/emails/billing/free-tier-upgrade-email.tsx b/apps/sim/components/emails/billing/free-tier-upgrade-email.tsx index 0e0f0a17e37..f565bc98129 100644 --- a/apps/sim/components/emails/billing/free-tier-upgrade-email.tsx +++ b/apps/sim/components/emails/billing/free-tier-upgrade-email.tsx @@ -105,7 +105,7 @@ export function FreeTierUpgradeEmail({
- One-time notification at 90% usage. + One-time notification at 80% usage. ) diff --git a/apps/sim/components/emails/billing/index.ts b/apps/sim/components/emails/billing/index.ts index 4624a2dc451..50ad7bd979a 100644 --- a/apps/sim/components/emails/billing/index.ts +++ b/apps/sim/components/emails/billing/index.ts @@ -1,5 +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' diff --git a/apps/sim/components/emails/render.ts b/apps/sim/components/emails/render.ts index 021529af3dd..78bb04457c0 100644 --- a/apps/sim/components/emails/render.ts +++ b/apps/sim/components/emails/render.ts @@ -8,6 +8,7 @@ import { import { AbandonedCheckoutEmail, CreditPurchaseEmail, + CreditsExhaustedEmail, EnterpriseSubscriptionEmail, FreeTierUpgradeEmail, PaymentFailedEmail, @@ -173,6 +174,15 @@ export async function renderAbandonedCheckoutEmail(userName?: string): Promise { + return await render(CreditsExhaustedEmail(params)) +} + export async function renderCreditPurchaseEmail(params: { userName?: string amount: number diff --git a/apps/sim/components/emails/subjects.ts b/apps/sim/components/emails/subjects.ts index c1d53633cfa..82f5f785852 100644 --- a/apps/sim/components/emails/subjects.ts +++ b/apps/sim/components/emails/subjects.ts @@ -17,6 +17,7 @@ export type EmailSubjectType = | 'plan-welcome-team' | 'credit-purchase' | 'abandoned-checkout' + | 'free-tier-exhausted' | 'onboarding-followup' | 'welcome' @@ -59,6 +60,8 @@ export function getEmailSubject(type: EmailSubjectType): string { 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': diff --git a/apps/sim/lib/billing/core/usage.ts b/apps/sim/lib/billing/core/usage.ts index d5154e80a04..3fe2a552fe5 100644 --- a/apps/sim/lib/billing/core/usage.ts +++ b/apps/sim/lib/billing/core/usage.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { eq, inArray } from 'drizzle-orm' import { getEmailSubject, + renderCreditsExhaustedEmail, renderFreeTierUpgradeEmail, renderUsageThresholdEmail, } from '@/components/emails' @@ -716,11 +717,13 @@ export async function maybeSendUsageThresholdEmail(params: { // Check for 80% threshold (all users) 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 80% threshold (free users only) + const crosses80Free = params.percentBefore < 80 && params.percentAfter >= 80 + // 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 && !crosses80Free && !crosses100) return // For 80% threshold email (all users) if (crosses80) { @@ -777,8 +780,8 @@ export async function maybeSendUsageThresholdEmail(params: { } } - // For 90% threshold email (free users only) - if (crosses90 && isFreeUser) { + // For 80% threshold email (free users only) + if (crosses80Free && isFreeUser) { const upgradeLink = `${baseUrl}/workspace?billing=upgrade` const sendFreeTierEmail = async (email: string, name?: string) => { const prefs = await getEmailPreferences(email) @@ -818,6 +821,45 @@ 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, + currentUsage: params.currentUsageAfter, + 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, From ad2fae40c21eea8189ea314ea04732af7568abe9 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 2 Apr 2026 18:32:18 -0700 Subject: [PATCH 03/13] feat(email): use wordmark in email header instead of icon-only logo --- .../emails/components/email-layout.tsx | 5 +++-- apps/sim/public/brand/color/email/wordmark.png | Bin 0 -> 2978 bytes 2 files changed, 3 insertions(+), 2 deletions(-) create mode 100644 apps/sim/public/brand/color/email/wordmark.png diff --git a/apps/sim/components/emails/components/email-layout.tsx b/apps/sim/components/emails/components/email-layout.tsx index deb1eba9be9..a60b76c7682 100644 --- a/apps/sim/components/emails/components/email-layout.tsx +++ b/apps/sim/components/emails/components/email-layout.tsx @@ -41,8 +41,9 @@ export function EmailLayout({ {/* Header with logo */}
{brand.name} diff --git a/apps/sim/public/brand/color/email/wordmark.png b/apps/sim/public/brand/color/email/wordmark.png new file mode 100644 index 0000000000000000000000000000000000000000..639a2ea80fc5fc4df2e27dfc3c8ea32116756433 GIT binary patch literal 2978 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 WhovH{)12Vw25hm8mj7TbCjTEhoP$9C literal 0 HcmV?d00001 From b48d754651739145f0621c7e9b4d4c3de31eb005 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 2 Apr 2026 18:33:18 -0700 Subject: [PATCH 04/13] fix(email): restore accidentally deleted social icons in email footer --- apps/sim/public/static/discord-icon.png | Bin 0 -> 346 bytes apps/sim/public/static/github-icon.png | Bin 0 -> 394 bytes apps/sim/public/static/x-icon.png | Bin 0 -> 407 bytes 3 files changed, 0 insertions(+), 0 deletions(-) create mode 100644 apps/sim/public/static/discord-icon.png create mode 100644 apps/sim/public/static/github-icon.png create mode 100644 apps/sim/public/static/x-icon.png diff --git a/apps/sim/public/static/discord-icon.png b/apps/sim/public/static/discord-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..7c645e7197a7606c7e901a9e58dbaad523c8022c GIT binary patch literal 346 zcmV-g0j2(lP)kdg0003YNklkdg0003|NklzCoTndHH{P!D3drvGldL1>r6P`D3rmsRJf*a4OX3M zPJZF{B+0Ok&h@rw#QJ_d(PEeGYkf8^$VN1RWUI-nx12UP<+7t+fT8FFIZXF{}tnIR1hZAe--(^j olOVXZ)*b!~g&Q literal 0 HcmV?d00001 diff --git a/apps/sim/public/static/x-icon.png b/apps/sim/public/static/x-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..58d635b3fb852dfcd1d423ae6f923bc830b9004e GIT binary patch literal 407 zcmV;I0cie-P)kdg0004ANklumn3eJ2++}oNv;>+JV=BC0K$bSOO*7&bUZ`e}Hv1JgNL9d?Cr# z$40%<@fz!#;@Ty}`6yfZhjr*GCI4OAuExHS0)L?%3GdUjLkj;G+w0}f$#&@MFGNQ?#I0l4v+vO@eb017M_Aaye2WC_L2vlJNkToGcx4h}4J67v$F-VWSHUe! z8~r%B<^s!*DEA!Ma$$45CT}1okBtjENg~%Y;uc3elYq>F9GY&D1Z~-#!-c&lO*1!F z$h_)6n;Sn9jEho%@p2@_`Wlwhms$;uVd_7g?oUZ98dC>=U26aU002ovPDHLkV1naU Bw6y>L literal 0 HcmV?d00001 From e060d50750254da1c6e30bc41e11f45045680cf5 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 2 Apr 2026 18:35:41 -0700 Subject: [PATCH 05/13] fix(email): prevent double email for free users at 80%, fix subject line --- apps/sim/components/emails/subjects.ts | 2 +- apps/sim/lib/billing/core/usage.ts | 12 +++++------- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/apps/sim/components/emails/subjects.ts b/apps/sim/components/emails/subjects.ts index 82f5f785852..d56a0a76526 100644 --- a/apps/sim/components/emails/subjects.ts +++ b/apps/sim/components/emails/subjects.ts @@ -51,7 +51,7 @@ 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': diff --git a/apps/sim/lib/billing/core/usage.ts b/apps/sim/lib/billing/core/usage.ts index 3fe2a552fe5..5bba4f86639 100644 --- a/apps/sim/lib/billing/core/usage.ts +++ b/apps/sim/lib/billing/core/usage.ts @@ -715,18 +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 (paid users only — free users get a more specific email below) const crosses80 = params.percentBefore < 80 && params.percentAfter >= 80 - // Check for 80% threshold (free users only) - const crosses80Free = params.percentBefore < 80 && params.percentAfter >= 80 // Check for 100% threshold (free users only — credits exhausted) const crosses100 = params.percentBefore < 100 && params.percentAfter >= 100 // Skip if no thresholds crossed - if (!crosses80 && !crosses80Free && !crosses100) 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) @@ -781,7 +779,7 @@ export async function maybeSendUsageThresholdEmail(params: { } // For 80% threshold email (free users only) - if (crosses80Free && isFreeUser) { + if (crosses80 && isFreeUser) { const upgradeLink = `${baseUrl}/workspace?billing=upgrade` const sendFreeTierEmail = async (email: string, name?: string) => { const prefs = await getEmailPreferences(email) From ffcc373c3b4169b8de4f5f79c65c5b87fa4a3adc Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 2 Apr 2026 18:43:27 -0700 Subject: [PATCH 06/13] improvement(emails): extract shared plain email styles and proFeatures constant, fix double email on 100% usage --- apps/sim/components/emails/_styles/base.ts | 21 +++++++++++++++++++ apps/sim/components/emails/_styles/index.ts | 2 +- .../emails/auth/onboarding-followup-email.tsx | 21 +------------------ .../components/emails/billing/_constants.ts | 7 +++++++ .../billing/abandoned-checkout-email.tsx | 21 +------------------ .../billing/credits-exhausted-email.tsx | 8 +------ .../billing/free-tier-upgrade-email.tsx | 8 +------ apps/sim/lib/billing/core/usage.ts | 4 ++-- 8 files changed, 35 insertions(+), 57 deletions(-) create mode 100644 apps/sim/components/emails/billing/_constants.ts diff --git a/apps/sim/components/emails/_styles/base.ts b/apps/sim/components/emails/_styles/base.ts index bc6d7b41d45..9f70efc075e 100644 --- a/apps/sim/components/emails/_styles/base.ts +++ b/apps/sim/components/emails/_styles/base.ts @@ -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 diff --git a/apps/sim/components/emails/_styles/index.ts b/apps/sim/components/emails/_styles/index.ts index dd1d961d5e0..3b252363815 100644 --- a/apps/sim/components/emails/_styles/index.ts +++ b/apps/sim/components/emails/_styles/index.ts @@ -1 +1 @@ -export { baseStyles, colors, spacing, typography } from './base' +export { baseStyles, colors, plainEmailStyles, spacing, typography } from './base' diff --git a/apps/sim/components/emails/auth/onboarding-followup-email.tsx b/apps/sim/components/emails/auth/onboarding-followup-email.tsx index 8e537deb73f..042b2fd29a1 100644 --- a/apps/sim/components/emails/auth/onboarding-followup-email.tsx +++ b/apps/sim/components/emails/auth/onboarding-followup-email.tsx @@ -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 ( diff --git a/apps/sim/components/emails/billing/_constants.ts b/apps/sim/components/emails/billing/_constants.ts new file mode 100644 index 00000000000..7767a4b8d08 --- /dev/null +++ b/apps/sim/components/emails/billing/_constants.ts @@ -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 diff --git a/apps/sim/components/emails/billing/abandoned-checkout-email.tsx b/apps/sim/components/emails/billing/abandoned-checkout-email.tsx index 969b3257f2d..dbf538baca2 100644 --- a/apps/sim/components/emails/billing/abandoned-checkout-email.tsx +++ b/apps/sim/components/emails/billing/abandoned-checkout-email.tsx @@ -1,29 +1,10 @@ import { Body, Head, Html, Preview, Text } from '@react-email/components' +import { plainEmailStyles as styles } from '@/components/emails/_styles' interface AbandonedCheckoutEmailProps { 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 AbandonedCheckoutEmail({ userName }: AbandonedCheckoutEmailProps) { return ( diff --git a/apps/sim/components/emails/billing/credits-exhausted-email.tsx b/apps/sim/components/emails/billing/credits-exhausted-email.tsx index 5fd36a82848..aca93b1f32b 100644 --- a/apps/sim/components/emails/billing/credits-exhausted-email.tsx +++ b/apps/sim/components/emails/billing/credits-exhausted-email.tsx @@ -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' @@ -11,13 +12,6 @@ interface CreditsExhaustedEmailProps { 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 CreditsExhaustedEmail({ userName, currentUsage, diff --git a/apps/sim/components/emails/billing/free-tier-upgrade-email.tsx b/apps/sim/components/emails/billing/free-tier-upgrade-email.tsx index f565bc98129..728b3ef32ee 100644 --- a/apps/sim/components/emails/billing/free-tier-upgrade-email.tsx +++ b/apps/sim/components/emails/billing/free-tier-upgrade-email.tsx @@ -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, diff --git a/apps/sim/lib/billing/core/usage.ts b/apps/sim/lib/billing/core/usage.ts index 5bba4f86639..9da67207e94 100644 --- a/apps/sim/lib/billing/core/usage.ts +++ b/apps/sim/lib/billing/core/usage.ts @@ -778,8 +778,8 @@ export async function maybeSendUsageThresholdEmail(params: { } } - // For 80% threshold email (free users only) - if (crosses80 && 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) From 07ca96a1c235207d8c2bcd6711239511ed894736 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 2 Apr 2026 18:44:31 -0700 Subject: [PATCH 07/13] fix(email): filter subscription-mode checkout, skip already-subscribed users, fix preview text --- .../billing/abandoned-checkout-email.tsx | 2 +- apps/sim/lib/billing/webhooks/checkout.ts | 23 ++++++++++--------- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/apps/sim/components/emails/billing/abandoned-checkout-email.tsx b/apps/sim/components/emails/billing/abandoned-checkout-email.tsx index dbf538baca2..adc3bb1379e 100644 --- a/apps/sim/components/emails/billing/abandoned-checkout-email.tsx +++ b/apps/sim/components/emails/billing/abandoned-checkout-email.tsx @@ -9,7 +9,7 @@ export function AbandonedCheckoutEmail({ userName }: AbandonedCheckoutEmailProps return ( - Quick question + Did you run into an issue with your upgrade?
{userName ? `Hi ${userName},` : 'Hi,'} diff --git a/apps/sim/lib/billing/webhooks/checkout.ts b/apps/sim/lib/billing/webhooks/checkout.ts index 9f71a98e53a..d9caa230b11 100644 --- a/apps/sim/lib/billing/webhooks/checkout.ts +++ b/apps/sim/lib/billing/webhooks/checkout.ts @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import type Stripe from 'stripe' import { getEmailSubject, renderAbandonedCheckoutEmail } from '@/components/emails' +import { hasPaidSubscription } from '@/lib/billing/core/subscription' import { sendEmail } from '@/lib/messaging/email/mailer' import { getPersonalEmailFrom } from '@/lib/messaging/email/utils' @@ -12,15 +13,17 @@ 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 { 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('[handleAbandonedCheckout] No customer ID on expired session', { - sessionId: session.id, - }) + logger.warn('No customer ID on expired session', { sessionId: session.id }) return } @@ -31,13 +34,14 @@ export async function handleAbandonedCheckout(event: Stripe.Event): Promise Date: Thu, 2 Apr 2026 18:58:46 -0700 Subject: [PATCH 08/13] fix(email): use notifications type for onboarding followup to respect unsubscribe preferences --- apps/sim/background/lifecycle-email.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/background/lifecycle-email.ts b/apps/sim/background/lifecycle-email.ts index 188fdd987ed..37dff97f8d3 100644 --- a/apps/sim/background/lifecycle-email.ts +++ b/apps/sim/background/lifecycle-email.ts @@ -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 }) From 0884025a26824ee3d9072af07c7452e722245fe4 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 2 Apr 2026 19:00:36 -0700 Subject: [PATCH 09/13] fix(email): use limit instead of currentUsage in credits exhausted email body --- .../components/emails/billing/credits-exhausted-email.tsx | 6 ++---- apps/sim/components/emails/render.ts | 1 - apps/sim/lib/billing/core/usage.ts | 1 - 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/apps/sim/components/emails/billing/credits-exhausted-email.tsx b/apps/sim/components/emails/billing/credits-exhausted-email.tsx index aca93b1f32b..261add80e97 100644 --- a/apps/sim/components/emails/billing/credits-exhausted-email.tsx +++ b/apps/sim/components/emails/billing/credits-exhausted-email.tsx @@ -7,14 +7,12 @@ import { getBrandConfig } from '@/ee/whitelabeling' interface CreditsExhaustedEmailProps { userName?: string - currentUsage: number limit: number upgradeLink: string } export function CreditsExhaustedEmail({ userName, - currentUsage, limit, upgradeLink, }: CreditsExhaustedEmailProps) { @@ -30,8 +28,8 @@ export function CreditsExhaustedEmail({ - You've used all {dollarsToCredits(currentUsage).toLocaleString()} of - your free credits on {brand.name}. Your workflows are paused until you upgrade. + You've used all {dollarsToCredits(limit).toLocaleString()} of your + free credits on {brand.name}. Your workflows are paused until you upgrade.
{ diff --git a/apps/sim/lib/billing/core/usage.ts b/apps/sim/lib/billing/core/usage.ts index 9da67207e94..1e06b20529d 100644 --- a/apps/sim/lib/billing/core/usage.ts +++ b/apps/sim/lib/billing/core/usage.ts @@ -829,7 +829,6 @@ export async function maybeSendUsageThresholdEmail(params: { const html = await renderCreditsExhaustedEmail({ userName: name, - currentUsage: params.currentUsageAfter, limit: params.limit, upgradeLink, }) From 40f7a7c7a8b05aec808c7e9994662a4520c01353 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 2 Apr 2026 19:04:10 -0700 Subject: [PATCH 10/13] fix(email): use notifications type for abandoned checkout, clarify crosses80 comment --- apps/sim/lib/billing/core/usage.ts | 2 +- apps/sim/lib/billing/webhooks/checkout.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/billing/core/usage.ts b/apps/sim/lib/billing/core/usage.ts index 1e06b20529d..658f856f6b8 100644 --- a/apps/sim/lib/billing/core/usage.ts +++ b/apps/sim/lib/billing/core/usage.ts @@ -715,7 +715,7 @@ export async function maybeSendUsageThresholdEmail(params: { const baseUrl = getBaseUrl() const isFreeUser = params.planName === 'Free' - // Check for 80% threshold (paid users only — free users get a more specific email below) + // 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 100% threshold (free users only — credits exhausted) const crosses100 = params.percentBefore < 100 && params.percentAfter >= 100 diff --git a/apps/sim/lib/billing/webhooks/checkout.ts b/apps/sim/lib/billing/webhooks/checkout.ts index d9caa230b11..f739e7881a8 100644 --- a/apps/sim/lib/billing/webhooks/checkout.ts +++ b/apps/sim/lib/billing/webhooks/checkout.ts @@ -51,7 +51,7 @@ export async function handleAbandonedCheckout(event: Stripe.Event): Promise Date: Thu, 2 Apr 2026 19:13:21 -0700 Subject: [PATCH 11/13] chore(email): rename _constants.ts to constants.ts --- .../components/emails/billing/{_constants.ts => constants.ts} | 0 apps/sim/components/emails/billing/credits-exhausted-email.tsx | 2 +- apps/sim/components/emails/billing/free-tier-upgrade-email.tsx | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename apps/sim/components/emails/billing/{_constants.ts => constants.ts} (100%) diff --git a/apps/sim/components/emails/billing/_constants.ts b/apps/sim/components/emails/billing/constants.ts similarity index 100% rename from apps/sim/components/emails/billing/_constants.ts rename to apps/sim/components/emails/billing/constants.ts diff --git a/apps/sim/components/emails/billing/credits-exhausted-email.tsx b/apps/sim/components/emails/billing/credits-exhausted-email.tsx index 261add80e97..4108a912624 100644 --- a/apps/sim/components/emails/billing/credits-exhausted-email.tsx +++ b/apps/sim/components/emails/billing/credits-exhausted-email.tsx @@ -1,6 +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 { proFeatures } from '@/components/emails/billing/constants' import { EmailLayout } from '@/components/emails/components' import { dollarsToCredits } from '@/lib/billing/credits/conversion' import { getBrandConfig } from '@/ee/whitelabeling' diff --git a/apps/sim/components/emails/billing/free-tier-upgrade-email.tsx b/apps/sim/components/emails/billing/free-tier-upgrade-email.tsx index 728b3ef32ee..f786a8e0700 100644 --- a/apps/sim/components/emails/billing/free-tier-upgrade-email.tsx +++ b/apps/sim/components/emails/billing/free-tier-upgrade-email.tsx @@ -1,6 +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 { proFeatures } from '@/components/emails/billing/constants' import { EmailLayout } from '@/components/emails/components' import { dollarsToCredits } from '@/lib/billing/credits/conversion' import { getBrandConfig } from '@/ee/whitelabeling' From 034b0f9882ed66f26fa5fb3761c7d65130f3dcb8 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 2 Apr 2026 19:18:00 -0700 Subject: [PATCH 12/13] fix(email): use isProPlan to catch org-level subscriptions in abandoned checkout guard --- apps/sim/lib/billing/webhooks/checkout.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/sim/lib/billing/webhooks/checkout.ts b/apps/sim/lib/billing/webhooks/checkout.ts index f739e7881a8..21f5a6a6e4b 100644 --- a/apps/sim/lib/billing/webhooks/checkout.ts +++ b/apps/sim/lib/billing/webhooks/checkout.ts @@ -4,7 +4,7 @@ import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import type Stripe from 'stripe' import { getEmailSubject, renderAbandonedCheckoutEmail } from '@/components/emails' -import { hasPaidSubscription } from '@/lib/billing/core/subscription' +import { isProPlan } from '@/lib/billing/core/subscription' import { sendEmail } from '@/lib/messaging/email/mailer' import { getPersonalEmailFrom } from '@/lib/messaging/email/utils' @@ -38,8 +38,8 @@ export async function handleAbandonedCheckout(event: Stripe.Event): Promise Date: Thu, 2 Apr 2026 19:29:23 -0700 Subject: [PATCH 13/13] fix(email): align onboarding followup delay to 5 days for email/password users --- apps/sim/lib/auth/auth.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 93ff7f39605..5a11d5b3798 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -616,7 +616,7 @@ export const auth = betterAuth({ await scheduleLifecycleEmail({ userId: user.id, type: 'onboarding-followup', - delayDays: 3, + delayDays: 5, }) } catch (error) { logger.error(