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(