From d8f02086fded50331c7c5b8be3b561ce0da08bcc Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 9 Aug 2026 17:15:48 -0700 Subject: [PATCH 1/6] improvement(emails): align the email design tokens with the platform design system --- apps/sim/components/emails/_styles/base.ts | 200 +++++++++++++----- apps/sim/components/emails/_styles/index.ts | 2 +- .../emails/auth/existing-account-email.tsx | 2 +- .../emails/auth/otp-verification-email.tsx | 2 +- .../emails/auth/reset-password-email.tsx | 2 +- .../components/emails/auth/welcome-email.tsx | 4 +- .../emails/billing/credit-purchase-email.tsx | 48 +---- .../billing/credits-exhausted-email.tsx | 64 +----- .../billing/enterprise-subscription-email.tsx | 2 +- .../billing/free-tier-upgrade-email.tsx | 65 +----- .../emails/billing/limit-threshold-email.tsx | 7 +- .../emails/billing/payment-failed-email.tsx | 62 ++---- .../emails/billing/plan-welcome-email.tsx | 4 +- .../emails/billing/pro-features-box.tsx | 53 +++++ .../billing/usage-limit-reached-email.tsx | 7 +- .../emails/billing/usage-threshold-email.tsx | 7 +- .../emails/components/email-footer.tsx | 9 +- .../invitations/batch-invitation-email.tsx | 2 +- .../emails/invitations/invitation-email.tsx | 2 +- .../invitations/workspace-added-email.tsx | 2 +- .../workspace-invitation-email.tsx | 2 +- .../notifications/schedule-disabled-email.tsx | 4 +- .../support/help-confirmation-email.tsx | 2 +- 23 files changed, 251 insertions(+), 303 deletions(-) create mode 100644 apps/sim/components/emails/billing/pro-features-box.tsx diff --git a/apps/sim/components/emails/_styles/base.ts b/apps/sim/components/emails/_styles/base.ts index 2ff9fcc74ac..93ef7f6f339 100644 --- a/apps/sim/components/emails/_styles/base.ts +++ b/apps/sim/components/emails/_styles/base.ts @@ -13,46 +13,70 @@ function buildColors() { isWhitelabeled && brand.theme?.primaryColor ? brand.theme.primaryColor : '#1a1a1a' return { - /** Main canvas background — a hair off-white so the white card reads via contrast, not the border alone */ - bgOuter: '#f8f8f8', + /** Canvas behind the card — platform `--surface-1` (the sidebar/panel surface) */ + bgOuter: '#fbfbfb', /** Card/container background — platform `--surface-2` */ bgCard: '#ffffff', - /** Primary text — platform `--text-primary` */ + /** Headings and emphasis — platform `--text-primary` */ textPrimary: '#1a1a1a', + /** Body and value text — platform `--text-body` */ + textBody: '#434343', /** Secondary text — platform `--text-secondary` */ textSecondary: '#525252', /** Tertiary text — platform `--text-tertiary` */ textTertiary: '#5c5c5c', - /** Muted text (footer) — platform `--text-muted` */ - textMuted: '#707070', + /** Muted text (labels, footer) — platform `--text-muted` */ + textMuted: '#7a7a7a', /** Brand primary — neutral by default, brand color when whitelabeled */ brandPrimary: isWhitelabeled && brand.theme?.primaryColor ? brand.theme.primaryColor : '#1a1a1a', /** Accent for buttons and links — neutral by default, brand color when whitelabeled */ brandTertiary: accentColor, - /** Border/divider — platform `--border` */ - divider: '#dedede', - /** Subtle fill for info/code boxes on the white card */ + /** Borders and dividers — platform `--border` */ + border: '#d8d8d8', + /** Fill for info/code boxes on the white card — platform `--surface-3` */ surfaceSubtle: '#f7f7f7', /** Error surface fill — platform `--terminal-status-error-bg` */ errorBg: '#fef2f2', /** Error surface border — platform `--error-muted` */ errorBorder: '#fecaca', + /** Text on an inverse (dark) fill, e.g. the CTA — platform `--text-inverse` */ + textInverse: '#ffffff', /** Footer background — matches the canvas */ - footerBg: '#f8f8f8', + footerBg: '#fbfbfb', } } export const colors = buildColors() -/** Typography settings */ +/** + * Typography settings. + * + * `fontSize` mirrors the platform scale in `apps/sim/tailwind.config.ts` and uses + * its names, so a value here is always traceable to a Tailwind token. Email body + * copy runs at `md` (16px) rather than the app's 15px `base` — 16px is the email + * client default and the only deviation, and it is still a real platform token. + */ export const typography = { fontFamily: "'Season Sans', -apple-system, 'SF Pro Display', 'SF Pro Text', 'Helvetica', sans-serif", fontSize: { - body: '16px', - small: '14px', + /** `text-caption` */ caption: '12px', + /** `text-small` */ + small: '13px', + /** `text-sm` — chip/button label size */ + sm: '14px', + /** `text-base` — the app's default body size */ + base: '15px', + /** `text-md` — email body copy */ + md: '16px', + /** + * Display figure (OTP code, credit balance). Deliberately above the platform + * scale — the app has no headline-numeral token because it has no surface + * that needs one. + */ + display: '24px', }, lineHeight: { body: '24px', @@ -60,6 +84,19 @@ export const typography = { }, } +/** + * Weight scale. The platform allows exactly three steps (400/500/600) — never + * `bold`, which resolves to 700 and sits off the scale. + */ +export const fontWeight = { + normal: 400, + medium: 500, + semibold: 600, +} as const + +/** Platform `--radius` (`rounded-lg`) — the single radius the design system uses. */ +const RADIUS = '8px' + /** Spacing values */ export const spacing = { containerWidth: 600, @@ -91,8 +128,8 @@ export const baseStyles = { maxWidth: `${spacing.containerWidth}px`, margin: '0 auto', backgroundColor: colors.bgCard, - border: `1px solid ${colors.divider}`, - borderRadius: '8px', + border: `1px solid ${colors.border}`, + borderRadius: RADIUS, overflow: 'hidden', }, @@ -107,37 +144,56 @@ export const baseStyles = { padding: `0 ${spacing.gutter}px 32px ${spacing.gutter}px`, }, - /** Standard paragraph text */ + /** Standard paragraph text — platform body copy (`--text-body`, normal weight) */ paragraph: { - fontSize: typography.fontSize.body, + fontSize: typography.fontSize.md, lineHeight: typography.lineHeight.body, - color: colors.textSecondary, - fontWeight: 400, + color: colors.textBody, + fontWeight: fontWeight.normal, fontFamily: typography.fontFamily, margin: `${spacing.paragraphGap}px 0`, }, - /** Bold label text (e.g., "Platform:", "Time:") */ + /** + * The opening line of an email body. Identical to {@link paragraph} but flush + * to the top, so it sits at a fixed distance below the logo instead of + * inheriting the paragraph gap. Half the templates spelled this out inline and + * half forgot it — always use the token. + */ + greeting: { + fontSize: typography.fontSize.md, + lineHeight: typography.lineHeight.body, + color: colors.textBody, + fontWeight: fontWeight.normal, + fontFamily: typography.fontFamily, + margin: `0 0 ${spacing.paragraphGap}px 0`, + }, + + /** Inline emphasized label (e.g., "Platform:", "Time:") */ label: { - fontSize: typography.fontSize.body, + fontSize: typography.fontSize.md, lineHeight: typography.lineHeight.body, - color: colors.textSecondary, - fontWeight: 'bold' as const, + color: colors.textPrimary, + fontWeight: fontWeight.semibold, fontFamily: typography.fontFamily, margin: 0, display: 'inline', }, - /** Primary CTA button - matches the platform's primary Chip (inverse fill, rounded-lg, h-30, text-sm) */ + /** + * Primary CTA — the platform's primary Chip, transcribed for email: + * `chipPrimaryFillTokens` fill (`--text-primary`), `chipGeometryClass` + * geometry (`h-[30px]`, `rounded-lg`, `px-2`, `text-sm`) at normal weight. + */ button: { display: 'inline-block', backgroundColor: colors.brandTertiary, - color: '#ffffff', - fontWeight: 400, - fontSize: '14px', + color: colors.textInverse, + fontWeight: fontWeight.normal, + fontSize: typography.fontSize.sm, lineHeight: '30px', - padding: '0 12px', - borderRadius: '8px', + padding: '0 8px', + borderRadius: RADIUS, textDecoration: 'none', textAlign: 'center' as const, margin: '4px 0', @@ -147,22 +203,25 @@ export const baseStyles = { /** Link text style - neutral color, so it carries an underline to read as a link */ link: { color: colors.brandTertiary, - fontWeight: 400, + fontWeight: fontWeight.normal, textDecoration: 'underline', }, /** Horizontal divider */ divider: { - borderTop: `1px solid ${colors.divider}`, + borderTop: `1px solid ${colors.border}`, margin: `16px 0`, }, - /** Footer container (inside gray area below card) */ - footer: { - maxWidth: `${spacing.containerWidth}px`, - margin: '0 auto', - padding: `32px ${spacing.gutter}px`, - textAlign: 'left' as const, + /** + * Footer link — muted rather than {@link link}'s accent, since the footer sits + * outside the card and its links are secondary to the message. + */ + footerLink: { + color: colors.textMuted, + fontWeight: fontWeight.normal, + textDecoration: 'underline', + fontFamily: typography.fontFamily, }, /** Footer text style */ @@ -179,15 +238,15 @@ export const baseStyles = { margin: '12px 0', padding: '12px 16px', backgroundColor: colors.surfaceSubtle, - borderRadius: '8px', - border: `1px solid ${colors.divider}`, + borderRadius: RADIUS, + border: `1px solid ${colors.border}`, textAlign: 'center' as const, }, /** Code/OTP text */ code: { - fontSize: '24px', - fontWeight: 'bold' as const, + fontSize: typography.fontSize.display, + fontWeight: fontWeight.semibold, letterSpacing: '3px', color: colors.textPrimary, fontFamily: typography.fontFamily, @@ -198,7 +257,7 @@ export const baseStyles = { codeBlock: { fontSize: typography.fontSize.caption, lineHeight: typography.lineHeight.caption, - color: colors.textSecondary, + color: colors.textBody, fontFamily: 'monospace', whiteSpace: 'pre-wrap' as const, wordWrap: 'break-word' as const, @@ -209,15 +268,24 @@ export const baseStyles = { infoBox: { backgroundColor: colors.surfaceSubtle, padding: '16px 18px', - borderRadius: '8px', + borderRadius: RADIUS, + margin: '16px 0', + }, + + /** Error-state variant of {@link infoBox} — same geometry, error fill and border */ + errorBox: { + backgroundColor: colors.errorBg, + border: `1px solid ${colors.errorBorder}`, + padding: '16px 18px', + borderRadius: RADIUS, margin: '16px 0', }, /** Info box title */ infoBoxTitle: { - fontSize: typography.fontSize.body, + fontSize: typography.fontSize.md, lineHeight: typography.lineHeight.body, - fontWeight: 600, + fontWeight: fontWeight.semibold, color: colors.textPrimary, fontFamily: typography.fontFamily, margin: '0 0 8px 0', @@ -225,13 +293,33 @@ export const baseStyles = { /** Info box list content */ infoBoxList: { - fontSize: typography.fontSize.body, + fontSize: typography.fontSize.md, lineHeight: '1.6', - color: colors.textSecondary, + color: colors.textBody, + fontFamily: typography.fontFamily, + margin: 0, + }, + + /** Muted caption inside an info box, above a {@link infoBoxValue} figure */ + infoBoxLabel: { + fontSize: typography.fontSize.sm, + lineHeight: typography.lineHeight.caption, + color: colors.textMuted, + fontWeight: fontWeight.normal, fontFamily: typography.fontFamily, margin: 0, }, + /** The headline figure of a stat info box (e.g. a credit balance) */ + infoBoxValue: { + fontSize: typography.fontSize.display, + lineHeight: '32px', + fontWeight: fontWeight.semibold, + color: colors.textPrimary, + fontFamily: typography.fontFamily, + margin: '4px 0 0 0', + }, + /** Section borders - decorative accent line */ sectionsBorders: { width: '100%', @@ -239,7 +327,7 @@ export const baseStyles = { }, sectionBorder: { - borderBottom: `1px solid ${colors.divider}`, + borderBottom: `1px solid ${colors.border}`, width: '249px', }, @@ -269,19 +357,25 @@ export const baseStyles = { /** Info row (e.g., Platform, Device location, Time) */ infoRow: { - fontSize: typography.fontSize.body, + fontSize: typography.fontSize.md, lineHeight: typography.lineHeight.body, - color: colors.textSecondary, + color: colors.textBody, fontFamily: typography.fontFamily, margin: '8px 0', }, } -/** Styles for plain personal emails (no branding, no EmailLayout) */ +/** + * Styles for plain personal emails (no branding, no EmailLayout). + * + * The system font stack is deliberate — these read as a message typed by a + * person, so they must NOT carry the brand face. Everything else still resolves + * through the shared tokens. + */ export const plainEmailStyles = { body: { fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', - backgroundColor: '#ffffff', + backgroundColor: colors.bgCard, margin: '0', padding: '0', }, @@ -291,9 +385,9 @@ export const plainEmailStyles = { padding: '0 24px', }, p: { - fontSize: '15px', + fontSize: typography.fontSize.base, lineHeight: '1.6', - color: '#1a1a1a', + color: colors.textPrimary, 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 3b252363815..5b603e2d856 100644 --- a/apps/sim/components/emails/_styles/index.ts +++ b/apps/sim/components/emails/_styles/index.ts @@ -1 +1 @@ -export { baseStyles, colors, plainEmailStyles, spacing, typography } from './base' +export { baseStyles, colors, fontWeight, plainEmailStyles, spacing, typography } from './base' diff --git a/apps/sim/components/emails/auth/existing-account-email.tsx b/apps/sim/components/emails/auth/existing-account-email.tsx index 0fc8f7e7524..637ab7df66f 100644 --- a/apps/sim/components/emails/auth/existing-account-email.tsx +++ b/apps/sim/components/emails/auth/existing-account-email.tsx @@ -23,7 +23,7 @@ export function ExistingAccountEmail({ username = '' }: ExistingAccountEmailProp preview={`Someone tried to sign up with your ${brand.name} email`} showUnsubscribe={false} > - Hello {username}, + Hello {username}, Someone just tried to create a {brand.name} account using this email address, but an account already exists. If this was you, sign in instead — or reset your password if you've diff --git a/apps/sim/components/emails/auth/otp-verification-email.tsx b/apps/sim/components/emails/auth/otp-verification-email.tsx index e23e938c5ab..5b60caf6854 100644 --- a/apps/sim/components/emails/auth/otp-verification-email.tsx +++ b/apps/sim/components/emails/auth/otp-verification-email.tsx @@ -37,7 +37,7 @@ export function OTPVerificationEmail({ return ( - Your verification code: + Your verification code:
{otp} diff --git a/apps/sim/components/emails/auth/reset-password-email.tsx b/apps/sim/components/emails/auth/reset-password-email.tsx index e86effe0fe8..cfdf82ff297 100644 --- a/apps/sim/components/emails/auth/reset-password-email.tsx +++ b/apps/sim/components/emails/auth/reset-password-email.tsx @@ -13,7 +13,7 @@ export function ResetPasswordEmail({ username = '', resetLink = '' }: ResetPassw return ( - Hello {username}, + Hello {username}, A password reset was requested for your {brand.name} account. Click below to set a new password. diff --git a/apps/sim/components/emails/auth/welcome-email.tsx b/apps/sim/components/emails/auth/welcome-email.tsx index d7f5b8643e7..4fbe5a88c8c 100644 --- a/apps/sim/components/emails/auth/welcome-email.tsx +++ b/apps/sim/components/emails/auth/welcome-email.tsx @@ -14,9 +14,7 @@ export function WelcomeEmail({ userName }: WelcomeEmailProps) { return ( - - {userName ? `Hey ${userName},` : 'Hey,'} - + {userName ? `Hey ${userName},` : 'Hey,'} Welcome to {brand.name}! Your account is ready. Start building, testing, and deploying AI workflows in minutes. diff --git a/apps/sim/components/emails/billing/credit-purchase-email.tsx b/apps/sim/components/emails/billing/credit-purchase-email.tsx index 8242587a284..f6f42fc0654 100644 --- a/apps/sim/components/emails/billing/credit-purchase-email.tsx +++ b/apps/sim/components/emails/billing/credit-purchase-email.tsx @@ -1,5 +1,5 @@ import { Link, Section, Text } from '@react-email/components' -import { baseStyles, colors } from '@/components/emails/_styles' +import { baseStyles } from '@/components/emails/_styles' import { EmailLayout } from '@/components/emails/components' import { dollarsToCredits } from '@/lib/billing/credits/conversion' import { getBaseUrl } from '@/lib/core/utils/urls' @@ -25,55 +25,19 @@ export function CreditPurchaseEmail({ return ( - - {userName ? `Hi ${userName},` : 'Hi,'} - + {userName ? `Hi ${userName},` : 'Hi,'} Your credit purchase of {dollarsToCredits(amount).toLocaleString()} credits{' '} has been confirmed.
- - Amount Added - - + Amount Added + {dollarsToCredits(amount).toLocaleString()} credits - - New Balance - - + New Balance + {dollarsToCredits(newBalance).toLocaleString()} credits
diff --git a/apps/sim/components/emails/billing/credits-exhausted-email.tsx b/apps/sim/components/emails/billing/credits-exhausted-email.tsx index d505e9e0726..8362efa52ba 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 { Link, Text } from '@react-email/components' +import { baseStyles } from '@/components/emails/_styles' +import { ProFeaturesBox } from '@/components/emails/billing/pro-features-box' import { EmailLayout } from '@/components/emails/components' import { dollarsToCredits } from '@/lib/billing/credits/conversion' import { getBrandConfig } from '@/ee/whitelabeling' @@ -23,68 +23,14 @@ export function CreditsExhaustedEmail({ preview={`You've used all ${dollarsToCredits(limit).toLocaleString()} of your free ${brand.name} credits`} showUnsubscribe={true} > - - {userName ? `Hi ${userName},` : 'Hi,'} - + {userName ? `Hi ${userName},` : 'Hi,'} You've used all {dollarsToCredits(limit).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 diff --git a/apps/sim/components/emails/billing/enterprise-subscription-email.tsx b/apps/sim/components/emails/billing/enterprise-subscription-email.tsx index 722fa3edee7..63eae26f0b5 100644 --- a/apps/sim/components/emails/billing/enterprise-subscription-email.tsx +++ b/apps/sim/components/emails/billing/enterprise-subscription-email.tsx @@ -22,7 +22,7 @@ export function EnterpriseSubscriptionEmail({ preview={`Your Enterprise Plan is now active on ${brand.name}`} showUnsubscribe={false} > - Hello {userName}, + Hello {userName}, Your Enterprise Plan is now active. You have full access to advanced features and increased capacity for your workflows. 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 cb4a1960dca..d10fcfaa706 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 { Link, Text } from '@react-email/components' +import { baseStyles } from '@/components/emails/_styles' +import { ProFeaturesBox } from '@/components/emails/billing/pro-features-box' import { EmailLayout } from '@/components/emails/components' import { dollarsToCredits } from '@/lib/billing/credits/conversion' import { getBrandConfig } from '@/ee/whitelabeling' @@ -26,9 +26,7 @@ export function FreeTierUpgradeEmail({ return ( - - {userName ? `Hi ${userName},` : 'Hi,'} - + {userName ? `Hi ${userName},` : 'Hi,'} You've used {dollarsToCredits(currentUsage).toLocaleString()} of your{' '} @@ -36,60 +34,7 @@ export function FreeTierUpgradeEmail({ Upgrade to Pro to keep building without interruption. - {/* Pro Features */} -
- - Pro includes - - - - {proFeatures.map((feature, i) => ( - - - - - ))} - -
- {feature.label} - - {feature.desc} -
-
+ Upgrade to Pro diff --git a/apps/sim/components/emails/billing/limit-threshold-email.tsx b/apps/sim/components/emails/billing/limit-threshold-email.tsx index 79f41df75e8..5a898f421ea 100644 --- a/apps/sim/components/emails/billing/limit-threshold-email.tsx +++ b/apps/sim/components/emails/billing/limit-threshold-email.tsx @@ -39,9 +39,7 @@ export function LimitThresholdEmail({ return ( - - {userName ? `Hi ${userName},` : 'Hi,'} - + {userName ? `Hi ${userName},` : 'Hi,'} {lead} Upgrade your plan for more {copy.noun}. @@ -54,9 +52,6 @@ export function LimitThresholdEmail({
- {/* Divider */} -
- Upgrade diff --git a/apps/sim/components/emails/billing/payment-failed-email.tsx b/apps/sim/components/emails/billing/payment-failed-email.tsx index 8f93c4fb2e6..dec36a0dbb8 100644 --- a/apps/sim/components/emails/billing/payment-failed-email.tsx +++ b/apps/sim/components/emails/billing/payment-failed-email.tsx @@ -1,5 +1,5 @@ import { Link, Section, Text } from '@react-email/components' -import { baseStyles, colors } from '@/components/emails/_styles' +import { baseStyles, colors, fontWeight } from '@/components/emails/_styles' import { EmailLayout } from '@/components/emails/components' import { getBrandConfig } from '@/ee/whitelabeling' @@ -26,15 +26,12 @@ export function PaymentFailedEmail({ return ( - - {userName ? `Hi ${userName},` : 'Hi,'} - + {userName ? `Hi ${userName},` : 'Hi,'} @@ -46,36 +43,22 @@ export function PaymentFailedEmail({ unexpected charges. To restore access immediately, please update your payment method. -
- - Payment Details - - - Amount due: ${amountDue.toFixed(2)} - +
+ Payment Details + Amount due: ${amountDue.toFixed(2)} {lastFourDigits && ( - - Payment method: •••• {lastFourDigits} - - )} - {failureReason && ( - Reason: {failureReason} + Payment method: •••• {lastFourDigits} )} + {failureReason && Reason: {failureReason}} +
+ +
+ What happens next + + • Your workflows and automations are currently paused +
• Update your payment method to restore service immediately +
• Stripe will automatically retry the charge once payment is updated +
@@ -85,17 +68,6 @@ export function PaymentFailedEmail({ {/* Divider */}
- What happens next? - - - • Your workflows and automations are currently paused -
• Update your payment method to restore service immediately -
• Stripe will automatically retry the charge once payment is updated -
- - {/* Divider */} -
- Common issues: expired card, insufficient funds, or incorrect billing info. Need help?{' '} diff --git a/apps/sim/components/emails/billing/plan-welcome-email.tsx b/apps/sim/components/emails/billing/plan-welcome-email.tsx index 3df89a59465..1b1363d5c5f 100644 --- a/apps/sim/components/emails/billing/plan-welcome-email.tsx +++ b/apps/sim/components/emails/billing/plan-welcome-email.tsx @@ -19,9 +19,7 @@ export function PlanWelcomeEmail({ planName, userName, loginLink }: PlanWelcomeE return ( - - {userName ? `Hi ${userName},` : 'Hi,'} - + {userName ? `Hi ${userName},` : 'Hi,'} Welcome to {planName}! You're all set to build, test, and scale your workflows. diff --git a/apps/sim/components/emails/billing/pro-features-box.tsx b/apps/sim/components/emails/billing/pro-features-box.tsx new file mode 100644 index 00000000000..9f2489ed9c4 --- /dev/null +++ b/apps/sim/components/emails/billing/pro-features-box.tsx @@ -0,0 +1,53 @@ +import { Section, Text } from '@react-email/components' +import { baseStyles, colors, fontWeight, typography } from '@/components/emails/_styles' +import { proFeatures } from '@/components/emails/billing/constants' + +/** + * The "Pro includes" panel shared by the two free-tier upgrade prompts + * ({@link CreditsExhaustedEmail}, {@link FreeTierUpgradeEmail}). + * + * A plain `infoBox` with a two-column table body — the table is what keeps the + * label and its qualifier on one row across email clients, which a list cannot + * do reliably. + */ +export function ProFeaturesBox() { + return ( +
+ Pro includes + + + {proFeatures.map((feature) => ( + + + + + ))} + +
+ {feature.label} + + {feature.desc} +
+
+ ) +} + +export default ProFeaturesBox diff --git a/apps/sim/components/emails/billing/usage-limit-reached-email.tsx b/apps/sim/components/emails/billing/usage-limit-reached-email.tsx index 8ff73ab52d9..56f5485e2c8 100644 --- a/apps/sim/components/emails/billing/usage-limit-reached-email.tsx +++ b/apps/sim/components/emails/billing/usage-limit-reached-email.tsx @@ -33,9 +33,7 @@ export function UsageLimitReachedEmail({ return ( - - {userName ? `Hi ${userName},` : 'Hi,'} - + {userName ? `Hi ${userName},` : 'Hi,'} {isOrganization @@ -53,9 +51,6 @@ export function UsageLimitReachedEmail({
- {/* Divider */} -
- {isOrganization ? 'Raise the organization usage limit in billing settings to resume.' diff --git a/apps/sim/components/emails/billing/usage-threshold-email.tsx b/apps/sim/components/emails/billing/usage-threshold-email.tsx index 4c4c6863e42..0db04ac687a 100644 --- a/apps/sim/components/emails/billing/usage-threshold-email.tsx +++ b/apps/sim/components/emails/billing/usage-threshold-email.tsx @@ -27,9 +27,7 @@ export function UsageThresholdEmail({ return ( - - {userName ? `Hi ${userName},` : 'Hi,'} - + {userName ? `Hi ${userName},` : 'Hi,'} You're approaching your monthly budget on the {planName} plan. @@ -43,9 +41,6 @@ export function UsageThresholdEmail({ - {/* Divider */} -
- To avoid interruptions, consider increasing your monthly limit. diff --git a/apps/sim/components/emails/components/email-footer.tsx b/apps/sim/components/emails/components/email-footer.tsx index ef85acd95b3..e43a514ef13 100644 --- a/apps/sim/components/emails/components/email-footer.tsx +++ b/apps/sim/components/emails/components/email-footer.tsx @@ -1,5 +1,5 @@ import { Container, Img, Link, Section } from '@react-email/components' -import { baseStyles, colors, spacing, typography } from '@/components/emails/_styles' +import { baseStyles, colors, spacing } from '@/components/emails/_styles' import { isHosted } from '@/lib/core/config/env-flags' import { getBaseUrl } from '@/lib/core/utils/urls' import { getBrandConfig } from '@/ee/whitelabeling' @@ -30,12 +30,7 @@ export function EmailFooter({ const brand = getBrandConfig() const isWhitelabeled = brand.isWhitelabeled - const footerLinkStyle = { - color: colors.textMuted, - textDecoration: 'underline', - fontWeight: 'normal' as const, - fontFamily: typography.fontFamily, - } + const footerLinkStyle = baseStyles.footerLink /** * Social icons are linked images. `display: block` removes the 2–3px gap diff --git a/apps/sim/components/emails/invitations/batch-invitation-email.tsx b/apps/sim/components/emails/invitations/batch-invitation-email.tsx index 9bd6a31ca5c..a5989707d44 100644 --- a/apps/sim/components/emails/invitations/batch-invitation-email.tsx +++ b/apps/sim/components/emails/invitations/batch-invitation-email.tsx @@ -58,7 +58,7 @@ export function BatchInvitationEmail({ preview={`You've been invited to join ${organizationName}${hasWorkspaces ? ` and ${workspaceInvitations.length} workspace(s)` : ''}`} showUnsubscribe={false} > - Hello, + Hello, {inviterName} has invited you to join {organizationName}{' '} on {brand.name}. diff --git a/apps/sim/components/emails/invitations/invitation-email.tsx b/apps/sim/components/emails/invitations/invitation-email.tsx index f3ce41dcf49..118cc6dc56c 100644 --- a/apps/sim/components/emails/invitations/invitation-email.tsx +++ b/apps/sim/components/emails/invitations/invitation-email.tsx @@ -40,7 +40,7 @@ export function InvitationEmail({ preview={`You've been invited to join ${organizationName} on ${brand.name}`} showUnsubscribe={false} > - Hello, + Hello, {inviterName} invited you to join {organizationName} on{' '} {brand.name}. diff --git a/apps/sim/components/emails/invitations/workspace-added-email.tsx b/apps/sim/components/emails/invitations/workspace-added-email.tsx index 3e291f48048..a23a6e51114 100644 --- a/apps/sim/components/emails/invitations/workspace-added-email.tsx +++ b/apps/sim/components/emails/invitations/workspace-added-email.tsx @@ -22,7 +22,7 @@ export function WorkspaceAddedEmail({ return ( - Hello, + Hello, {inviterName} added you to the {workspaceName} workspace on {brand.name}. diff --git a/apps/sim/components/emails/invitations/workspace-invitation-email.tsx b/apps/sim/components/emails/invitations/workspace-invitation-email.tsx index 819388edfbc..bf73c26b354 100644 --- a/apps/sim/components/emails/invitations/workspace-invitation-email.tsx +++ b/apps/sim/components/emails/invitations/workspace-invitation-email.tsx @@ -23,7 +23,7 @@ export function WorkspaceInvitationEmail({ return ( - Hello, + Hello, {inviterName} invited you to join the{' '} {workspaceNames.map((name, index) => ( diff --git a/apps/sim/components/emails/notifications/schedule-disabled-email.tsx b/apps/sim/components/emails/notifications/schedule-disabled-email.tsx index e221cce3200..ac022eb84b0 100644 --- a/apps/sim/components/emails/notifications/schedule-disabled-email.tsx +++ b/apps/sim/components/emails/notifications/schedule-disabled-email.tsx @@ -39,9 +39,7 @@ export function ScheduleDisabledEmail({ return ( - - {recipientName ? `Hi ${recipientName},` : 'Hi,'} - + {recipientName ? `Hi ${recipientName},` : 'Hi,'} {brand.name} turned off the schedule for {resourceLabel}. It will not run again until you diff --git a/apps/sim/components/emails/support/help-confirmation-email.tsx b/apps/sim/components/emails/support/help-confirmation-email.tsx index 6e2b0726c64..9c12e35f2ce 100644 --- a/apps/sim/components/emails/support/help-confirmation-email.tsx +++ b/apps/sim/components/emails/support/help-confirmation-email.tsx @@ -36,7 +36,7 @@ export function HelpConfirmationEmail({ preview={`Your ${typeLabel.toLowerCase()} has been received`} showUnsubscribe={false} > - Hello, + Hello, We've received your {typeLabel.toLowerCase()} and will get back to you shortly. From d6d925c996b6fda39528b43337c01e5c18e8d296 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 9 Aug 2026 17:23:29 -0700 Subject: [PATCH 2/6] improvement(emails): preview every template in a single gallery page --- apps/sim/app/api/emails/preview/route.ts | 143 ++++++++++++++++++----- 1 file changed, 115 insertions(+), 28 deletions(-) diff --git a/apps/sim/app/api/emails/preview/route.ts b/apps/sim/app/api/emails/preview/route.ts index 77ec9673d1e..3ab4ad56fb6 100644 --- a/apps/sim/app/api/emails/preview/route.ts +++ b/apps/sim/app/api/emails/preview/route.ts @@ -1,12 +1,17 @@ import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import { + renderAbandonedCheckoutEmail, renderBatchInvitationEmail, renderCreditPurchaseEmail, + renderCreditsExhaustedEmail, renderEnterpriseSubscriptionEmail, + renderExistingAccountEmail, renderFreeTierUpgradeEmail, renderHelpConfirmationEmail, renderInvitationEmail, + renderLimitThresholdEmail, + renderOnboardingFollowupEmail, renderOTPEmail, renderPasswordResetEmail, renderPaymentFailedEmail, @@ -15,6 +20,7 @@ import { renderUsageLimitReachedEmail, renderUsageThresholdEmail, renderWelcomeEmail, + renderWorkspaceAddedEmail, renderWorkspaceInvitationEmail, } from '@/components/emails' import { emailPreviewQuerySchema } from '@/lib/api/contracts/common' @@ -24,11 +30,16 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const emailTemplates = { // Auth emails otp: () => renderOTPEmail('123456', 'user@example.com', 'email-verification'), + 'otp-sign-in': () => renderOTPEmail('123456', 'user@example.com', 'sign-in'), 'reset-password': () => renderPasswordResetEmail('John', 'https://sim.ai/reset?token=abc123'), + 'existing-account': () => renderExistingAccountEmail('John'), welcome: () => renderWelcomeEmail('John'), + 'onboarding-followup': () => renderOnboardingFollowupEmail('John'), // Invitation emails invitation: () => renderInvitationEmail('Jane Doe', 'Acme Corp', 'https://sim.ai/invite/abc123'), + 'workspace-added': () => + renderWorkspaceAddedEmail('Jane Doe', 'Engineering', 'https://sim.ai/workspace/ws_123'), 'batch-invitation': () => renderBatchInvitationEmail( 'Jane Doe', @@ -87,6 +98,43 @@ const emailTemplates = { amount: 50, newBalance: 75, }), + 'credits-exhausted': () => + renderCreditsExhaustedEmail({ + userName: 'John', + limit: 10, + upgradeLink: 'https://sim.ai/settings/billing', + }), + 'abandoned-checkout': () => renderAbandonedCheckoutEmail('John'), + 'limit-threshold-storage-warning': () => + renderLimitThresholdEmail({ + kind: 'warning', + reason: 'storage', + userName: 'John', + usageLabel: '4.2 GB', + limitLabel: '5 GB', + percentUsed: 84, + upgradeLink: 'https://sim.ai/settings/billing', + }), + 'limit-threshold-tables-reached': () => + renderLimitThresholdEmail({ + kind: 'reached', + reason: 'tables', + userName: 'John', + usageLabel: '50,000 rows', + limitLabel: '50,000 rows', + percentUsed: 100, + upgradeLink: 'https://sim.ai/settings/billing', + }), + 'limit-threshold-seats-reached': () => + renderLimitThresholdEmail({ + kind: 'reached', + reason: 'seats', + userName: 'John', + usageLabel: '10 seats', + limitLabel: '10 seats', + percentUsed: 100, + upgradeLink: 'https://sim.ai/settings/billing', + }), 'payment-failed': () => renderPaymentFailedEmail({ userName: 'John', @@ -138,6 +186,40 @@ function isEmailTemplate(template: string): template is EmailTemplate { return template in emailTemplates } +const CATEGORIZED = { + Auth: ['otp', 'otp-sign-in', 'reset-password', 'existing-account', 'welcome'], + Invitations: ['invitation', 'batch-invitation', 'workspace-invitation', 'workspace-added'], + Support: ['help-confirmation'], + Billing: [ + 'usage-threshold', + 'usage-limit-reached', + 'usage-limit-reached-org', + 'free-tier-upgrade', + 'credits-exhausted', + 'limit-threshold-storage-warning', + 'limit-threshold-tables-reached', + 'limit-threshold-seats-reached', + 'payment-failed', + 'credit-purchase', + 'plan-welcome-pro', + 'plan-welcome-team', + 'enterprise-subscription', + ], + Notifications: ['schedule-disabled', 'schedule-disabled-auth'], + 'Plain (unbranded)': ['onboarding-followup', 'abandoned-checkout'], +} satisfies Record + +/** + * Category map for the gallery, with any template missing from {@link CATEGORIZED} + * appended rather than dropped — so a newly registered template always shows up + * even if nobody remembers to file it. + */ +const PREVIEW_CATEGORIES: Record = (() => { + const filed = new Set(Object.values(CATEGORIZED).flat()) + const unfiled = (Object.keys(emailTemplates) as EmailTemplate[]).filter((t) => !filed.has(t)) + return unfiled.length > 0 ? { ...CATEGORIZED, Uncategorized: unfiled } : CATEGORIZED +})() + export const GET = withRouteHandler(async (request: NextRequest) => { const { searchParams } = new URL(request.url) const queryValidation = emailPreviewQuerySchema.safeParse( @@ -147,32 +229,23 @@ export const GET = withRouteHandler(async (request: NextRequest) => { const { template } = queryValidation.data if (!template) { - const categories = { - Auth: ['otp', 'reset-password', 'welcome'], - Invitations: ['invitation', 'batch-invitation', 'workspace-invitation'], - Support: ['help-confirmation'], - Billing: [ - 'usage-threshold', - 'enterprise-subscription', - 'free-tier-upgrade', - 'plan-welcome-pro', - 'plan-welcome-team', - 'credit-purchase', - 'payment-failed', - 'usage-limit-reached', - 'usage-limit-reached-org', - ], - Notifications: ['schedule-disabled', 'schedule-disabled-auth'], - } - - const categoryHtml = Object.entries(categories) + const categoryHtml = Object.entries(PREVIEW_CATEGORIES) .map( ([category, templates]) => ` -

${category}

-
    - ${templates.map((t) => `
  • ${t}
  • `).join('')} -
- ` +
+

${category}

+
+ ${templates + .map( + (t) => ` +
+
${t}open ↗
+ +
` + ) + .join('')} +
+
` ) .join('') @@ -180,15 +253,29 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ` - Email Previews + + + Email Templates

Email Templates

+

Every email Sim sends — ${Object.values(PREVIEW_CATEGORIES).flat().length} previews.

${categoryHtml} `, From 34d3ab87a537fbb4beaff5986fb70d2ded7ca6dc Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 9 Aug 2026 17:32:18 -0700 Subject: [PATCH 3/6] improvement(emails): align font stack, logo and social-icon sizing with the platform --- apps/sim/components/emails/_styles/base.ts | 11 +- .../emails/components/email-footer.tsx | 117 +++++++++--------- .../emails/components/email-layout.tsx | 25 +++- 3 files changed, 88 insertions(+), 65 deletions(-) diff --git a/apps/sim/components/emails/_styles/base.ts b/apps/sim/components/emails/_styles/base.ts index 93ef7f6f339..3fb9edb2336 100644 --- a/apps/sim/components/emails/_styles/base.ts +++ b/apps/sim/components/emails/_styles/base.ts @@ -58,8 +58,15 @@ export const colors = buildColors() * client default and the only deviation, and it is still a real platform token. */ export const typography = { + /** + * Mirrors the platform face and its fallback chain + * (`apps/sim/app/_styles/fonts/season/season.ts`). This matters more than the + * `` webfont in `EmailLayout` — Gmail and Outlook strip `@font-face` + * entirely, so for most recipients the fallback chain IS the rendered font. + * It previously listed Apple system faces the platform never uses. + */ fontFamily: - "'Season Sans', -apple-system, 'SF Pro Display', 'SF Pro Text', 'Helvetica', sans-serif", + "'Season Sans', system-ui, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif", fontSize: { /** `text-caption` */ caption: '12px', @@ -103,8 +110,6 @@ export const spacing = { gutter: 40, sectionGap: 20, paragraphGap: 12, - /** Logo width in pixels */ - logoWidth: 90, } export const baseStyles = { diff --git a/apps/sim/components/emails/components/email-footer.tsx b/apps/sim/components/emails/components/email-footer.tsx index e43a514ef13..3b2fe387fdd 100644 --- a/apps/sim/components/emails/components/email-footer.tsx +++ b/apps/sim/components/emails/components/email-footer.tsx @@ -4,6 +4,32 @@ import { isHosted } from '@/lib/core/config/env-flags' import { getBaseUrl } from '@/lib/core/utils/urls' import { getBrandConfig } from '@/ee/whitelabeling' +/** + * Social mark display size. Every `static/*-icon.png` is 40×40, so 20px is a + * clean 2x source — email clients do no responsive image selection, so the + * asset must be authored at 2x and pinned here. This is deliberately NOT the + * platform's 14px UI-icon size: these are brand marks in an image, not + * `--text-icon` glyphs, and 14px renders them illegibly. + */ +const SOCIAL_ICON_SIZE = 20 + +/** Gap between social marks. Halved on each side, so the row reads evenly. */ +const SOCIAL_ICON_GAP = 8 + +/** + * `display: block` removes the 2–3px gap Outlook adds under inline images; + * `border: 0` prevents the blue link border older Outlook draws around a linked + * image. + */ +const socialIconStyle = { display: 'block' as const, border: 0 } + +const SOCIAL_LINKS = [ + { path: 'x', label: 'X', icon: 'x-icon.png' }, + { path: 'linkedin', label: 'LinkedIn', icon: 'linkedin-icon.png' }, + { path: 'github', label: 'GitHub', icon: 'github-icon.png' }, + { path: 'slack', label: 'Slack', icon: 'slack-icon.png' }, +] as const + interface EmailFooterProps { baseUrl?: string messageId?: string @@ -32,16 +58,6 @@ export function EmailFooter({ const footerLinkStyle = baseStyles.footerLink - /** - * Social icons are linked images. `display: block` removes the 2–3px gap - * Outlook adds under inline images, and `border: 0` prevents the blue link - * border older Outlook versions draw around linked images. - */ - const socialIconStyle = { - display: 'block' as const, - border: 0, - } - return (
- - - X - - - - - LinkedIn - - - - - GitHub - - - - - Slack - - + {SOCIAL_LINKS.map(({ path, label, icon }, index) => ( + + + {label} + + + ))} @@ -167,6 +159,11 @@ export function EmailFooter({ Questions?{' '} + {/* + A raw anchor, not ``: react-email's Link hardcodes + target="_blank", which on a mailto: opens a blank tab beside + the compose window in most webmail clients. + */} {brand.supportEmail} @@ -210,24 +207,24 @@ export function EmailFooter({   - + Privacy Policy - {' '} + {' '} •{' '} - + Terms of Service - + {showUnsubscribe && ( <> {' '} •{' '} - Unsubscribe - + )} diff --git a/apps/sim/components/emails/components/email-layout.tsx b/apps/sim/components/emails/components/email-layout.tsx index 264fd177cdb..d7710800061 100644 --- a/apps/sim/components/emails/components/email-layout.tsx +++ b/apps/sim/components/emails/components/email-layout.tsx @@ -4,6 +4,18 @@ import { EmailFooter } from '@/components/emails/components/email-footer' import { getBaseUrl } from '@/lib/core/utils/urls' import { getBrandConfig } from '@/ee/whitelabeling' +/** + * Wordmark display size. `wordmark.png` is 272×164, so these are exactly 1/4 of + * its intrinsic size — the asset is a 4x source and must stay that way, since + * email clients do no responsive image selection. Changing one of these without + * the other distorts the mark. + */ +const WORDMARK_HEIGHT = 41 +const WORDMARK_WIDTH = 68 + +/** Whitelabeled logos are arbitrary aspect ratios, so only height is pinned. */ +const CUSTOM_LOGO_HEIGHT = 34 + interface EmailLayoutProps { /** Preview text shown in email client list view */ preview: string @@ -35,9 +47,16 @@ export function EmailLayout({ return ( + {/* + `fallbackFontFamily` only accepts react-email's fixed union, so it + cannot express the platform's full chain (`system-ui`, `Segoe UI`, + `Roboto`, …) — this is the closest allowed subset. The complete chain + lives on `typography.fontFamily`, which is applied inline to every + element and is what clients that strip `@font-face` actually use. + */}
From 79a76ecf34abd0cc468887ff8f597b45b5c8390c Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 9 Aug 2026 17:52:20 -0700 Subject: [PATCH 4/6] improvement(emails): tokenize the CTA and footnote, and enforce the platform mirror with a test --- apps/sim/app/api/emails/preview/route.ts | 17 +- .../emails/_styles/base.tokens.test.ts | 129 ++++++++++++++ apps/sim/components/emails/_styles/base.ts | 160 ++++++------------ .../emails/auth/existing-account-email.tsx | 10 +- .../emails/auth/onboarding-followup-email.tsx | 7 +- .../emails/auth/otp-verification-email.tsx | 3 +- .../emails/auth/reset-password-email.tsx | 11 +- .../components/emails/auth/welcome-email.tsx | 9 +- .../billing/abandoned-checkout-email.tsx | 8 +- .../emails/billing/credit-purchase-email.tsx | 16 +- .../billing/credits-exhausted-email.tsx | 14 +- .../billing/enterprise-subscription-email.tsx | 27 ++- .../billing/free-tier-upgrade-email.tsx | 19 +-- .../emails/billing/limit-threshold-email.tsx | 11 +- .../emails/billing/payment-failed-email.tsx | 9 +- .../emails/billing/plan-welcome-email.tsx | 15 +- .../emails/billing/pro-features-box.tsx | 44 ++--- .../billing/usage-limit-reached-email.tsx | 15 +- .../emails/billing/usage-threshold-email.tsx | 13 +- .../emails/components/email-button.tsx | 28 +++ .../emails/components/email-footer.tsx | 39 ++--- .../emails/components/email-layout.tsx | 20 +-- .../emails/components/email-strong.tsx | 21 +++ .../sim/components/emails/components/index.ts | 2 + .../invitations/batch-invitation-email.tsx | 50 +++--- .../emails/invitations/invitation-email.tsx | 15 +- .../invitations/workspace-added-email.tsx | 16 +- .../workspace-invitation-email.tsx | 14 +- .../notifications/schedule-disabled-email.tsx | 13 +- .../support/help-confirmation-email.tsx | 9 +- 30 files changed, 406 insertions(+), 358 deletions(-) create mode 100644 apps/sim/components/emails/_styles/base.tokens.test.ts create mode 100644 apps/sim/components/emails/components/email-button.tsx create mode 100644 apps/sim/components/emails/components/email-strong.tsx diff --git a/apps/sim/app/api/emails/preview/route.ts b/apps/sim/app/api/emails/preview/route.ts index 3ab4ad56fb6..63cfb4994dd 100644 --- a/apps/sim/app/api/emails/preview/route.ts +++ b/apps/sim/app/api/emails/preview/route.ts @@ -23,6 +23,7 @@ import { renderWorkspaceAddedEmail, renderWorkspaceInvitationEmail, } from '@/components/emails' +import { colors, typography } from '@/components/emails/_styles' import { emailPreviewQuerySchema } from '@/lib/api/contracts/common' import { validationErrorResponse } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -258,24 +259,24 @@ export const GET = withRouteHandler(async (request: NextRequest) => { Email Templates

Email Templates

-

Every email Sim sends — ${Object.values(PREVIEW_CATEGORIES).flat().length} previews.

+

Every email Sim sends — ${Object.keys(emailTemplates).length} previews.

${categoryHtml} `, diff --git a/apps/sim/components/emails/_styles/base.tokens.test.ts b/apps/sim/components/emails/_styles/base.tokens.test.ts new file mode 100644 index 00000000000..c1223ebec42 --- /dev/null +++ b/apps/sim/components/emails/_styles/base.tokens.test.ts @@ -0,0 +1,129 @@ +/** + * Email styles cannot use CSS variables — clients strip them — so `base.ts` + * hardcodes hex copies of the platform tokens. Nothing else detects it when + * `globals.css`, `tailwind.config.ts`, or the chip chrome moves and the copies + * go stale, which is exactly how they drifted before. This suite is that + * detector. + * + * @vitest-environment node + */ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { baseStyles, colors, typography } from '@/components/emails/_styles' + +const APP_ROOT = join(__dirname, '../../..') + +const globalsCss = readFileSync(join(APP_ROOT, 'app/_styles/globals.css'), 'utf8') +const tailwindConfig = readFileSync(join(APP_ROOT, 'tailwind.config.ts'), 'utf8') +const chipChrome = readFileSync( + join(APP_ROOT, '../../packages/emcn/src/components/chip/chip-chrome.ts'), + 'utf8' +) + +/** + * The light-mode `:root` block. Dark mode redefines the same names later in the + * file, and emails are light-only, so the FIRST definition is the one to read. + */ +function readCssVar(name: string): string { + const match = globalsCss.match(new RegExp(`--${name}:\\s*([^;]+);`)) + if (!match) throw new Error(`--${name} not found in globals.css`) + return match[1].trim() +} + +function readTailwindFontSize(name: string): string { + const match = tailwindConfig.match(new RegExp(`\\b${name}:\\s*'([^']+)'`)) + if (!match) throw new Error(`fontSize.${name} not found in tailwind.config.ts`) + return match[1] +} + +/** Every email color token and the platform variable it copies. */ +const COLOR_MIRROR: Record = { + bgOuter: 'surface-1', + bgCard: 'surface-2', + surfaceSubtle: 'surface-3', + textPrimary: 'text-primary', + textBody: 'text-body', + textMuted: 'text-muted', + textInverse: 'text-inverse', + border: 'border', + errorBg: 'terminal-status-error-bg', + errorBorder: 'error-muted', + footerBg: 'surface-1', +} + +/** + * Tokens with no single CSS variable behind them. Each needs a stated reason — + * an entry here is a deliberate exception, not an oversight. + */ +const UNMIRRORED_COLORS: Record = { + brandTertiary: 'Runtime-conditional on getBrandConfig(); neutral default equals --text-primary.', +} + +describe('email color tokens mirror globals.css', () => { + for (const [token, cssVar] of Object.entries(COLOR_MIRROR)) { + it(`colors.${token} equals --${cssVar}`, () => { + expect(colors[token as keyof typeof colors]).toBe(readCssVar(cssVar)) + }) + } + + it('every color token is either mirrored or has a written exemption', () => { + const accounted = new Set([...Object.keys(COLOR_MIRROR), ...Object.keys(UNMIRRORED_COLORS)]) + const unaccounted = Object.keys(colors).filter((key) => !accounted.has(key)) + expect(unaccounted).toEqual([]) + }) + + it('exemptions state a reason', () => { + for (const reason of Object.values(UNMIRRORED_COLORS)) { + expect(reason.trim().length).toBeGreaterThan(0) + } + }) +}) + +describe('email type scale mirrors tailwind.config.ts', () => { + it.each(['caption', 'base', 'md'])('fontSize.%s matches the Tailwind token', (name) => { + expect(typography.fontSize[name as 'caption' | 'base' | 'md']).toBe(readTailwindFontSize(name)) + }) + + it('sm is Tailwind stock 14px — the size text-sm resolves to in chip chrome', () => { + expect(typography.fontSize.sm).toBe('14px') + expect(chipChrome).toContain('text-sm') + }) + + it('display is deliberately off-scale (no platform headline-numeral token)', () => { + expect(typography.fontSize.display).toBe('24px') + expect(tailwindConfig).not.toContain("'24px'") + }) +}) + +describe('email geometry mirrors the platform', () => { + it('the card radius equals --radius', () => { + // --radius is authored in rem; emails need px. + expect(readCssVar('radius')).toBe('0.5rem') + expect(baseStyles.container.borderRadius).toBe('8px') + }) + + it('the CTA transcribes chipGeometryClass', () => { + const geometry = chipChrome.match(/chipGeometryClass = `([^`]+)`/)?.[1] + expect(geometry).toBeDefined() + expect(geometry).toContain('h-[30px]') + expect(geometry).toContain('rounded-lg') + expect(geometry).toContain('px-2') + expect(geometry).toContain('text-sm') + + expect(baseStyles.button.lineHeight).toBe('30px') + expect(baseStyles.button.borderRadius).toBe('8px') + expect(baseStyles.button.padding).toBe('0 8px') + expect(baseStyles.button.fontSize).toBe(typography.fontSize.sm) + }) +}) + +describe('email font weights stay on the platform scale', () => { + it('no token uses a weight outside 400/500/600', () => { + const offScale = Object.entries(baseStyles).filter(([, style]) => { + const weight = (style as { fontWeight?: unknown }).fontWeight + return weight !== undefined && ![400, 500, 600].includes(weight as number) + }) + expect(offScale.map(([name]) => name)).toEqual([]) + }) +}) diff --git a/apps/sim/components/emails/_styles/base.ts b/apps/sim/components/emails/_styles/base.ts index 3fb9edb2336..2559fbcc157 100644 --- a/apps/sim/components/emails/_styles/base.ts +++ b/apps/sim/components/emails/_styles/base.ts @@ -21,15 +21,8 @@ function buildColors() { textPrimary: '#1a1a1a', /** Body and value text — platform `--text-body` */ textBody: '#434343', - /** Secondary text — platform `--text-secondary` */ - textSecondary: '#525252', - /** Tertiary text — platform `--text-tertiary` */ - textTertiary: '#5c5c5c', /** Muted text (labels, footer) — platform `--text-muted` */ textMuted: '#7a7a7a', - /** Brand primary — neutral by default, brand color when whitelabeled */ - brandPrimary: - isWhitelabeled && brand.theme?.primaryColor ? brand.theme.primaryColor : '#1a1a1a', /** Accent for buttons and links — neutral by default, brand color when whitelabeled */ brandTertiary: accentColor, /** Borders and dividers — platform `--border` */ @@ -50,12 +43,8 @@ function buildColors() { export const colors = buildColors() /** - * Typography settings. - * - * `fontSize` mirrors the platform scale in `apps/sim/tailwind.config.ts` and uses - * its names, so a value here is always traceable to a Tailwind token. Email body - * copy runs at `md` (16px) rather than the app's 15px `base` — 16px is the email - * client default and the only deviation, and it is still a real platform token. + * Typography settings. Enforced against the platform sources by + * `base.tokens.test.ts`. */ export const typography = { /** @@ -63,20 +52,24 @@ export const typography = { * (`apps/sim/app/_styles/fonts/season/season.ts`). This matters more than the * `` webfont in `EmailLayout` — Gmail and Outlook strip `@font-face` * entirely, so for most recipients the fallback chain IS the rendered font. - * It previously listed Apple system faces the platform never uses. */ fontFamily: "'Season Sans', system-ui, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, 'Noto Sans', sans-serif", + /** + * Deliberately brand-free, for the plain personal emails — those read as a + * message typed by a person, so they must NOT carry the brand face. + */ + systemFontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', + /** + * `caption`/`base`/`md` are Sim's own scale from `tailwind.config.ts`. `sm` is + * Tailwind's stock 14px — not a Sim token, but what `text-sm` resolves to in + * `chipGeometryClass`, so the CTA has to use it. + */ fontSize: { - /** `text-caption` */ caption: '12px', - /** `text-small` */ - small: '13px', - /** `text-sm` — chip/button label size */ sm: '14px', - /** `text-base` — the app's default body size */ base: '15px', - /** `text-md` — email body copy */ + /** Email body copy. Larger than the app's 15px `base` — the client default. */ md: '16px', /** * Display figure (OTP code, credit balance). Deliberately above the platform @@ -108,13 +101,26 @@ const RADIUS = '8px' export const spacing = { containerWidth: 600, gutter: 40, - sectionGap: 20, paragraphGap: 12, } -export const baseStyles = { +/** Shared body-copy ramp. {@link baseStyles.paragraph} and `greeting` differ only in margin. */ +const bodyText = { + fontSize: typography.fontSize.md, + lineHeight: typography.lineHeight.body, + color: colors.textBody, + fontWeight: fontWeight.normal, fontFamily: typography.fontFamily, +} + +/** Shared box geometry. {@link baseStyles.infoBox} and `errorBox` differ only in fill. */ +const boxGeometry = { + padding: '16px 18px', + borderRadius: RADIUS, + margin: '16px 0', +} +export const baseStyles = { /** Main body wrapper with outer background */ main: { backgroundColor: colors.bgOuter, @@ -122,12 +128,6 @@ export const baseStyles = { padding: '32px 0', }, - /** Center wrapper for email content */ - wrapper: { - maxWidth: `${spacing.containerWidth}px`, - margin: '0 auto', - }, - /** Main card container — white surface, chip-radius, hairline border on the near-white canvas */ container: { maxWidth: `${spacing.containerWidth}px`, @@ -149,42 +149,21 @@ export const baseStyles = { padding: `0 ${spacing.gutter}px 32px ${spacing.gutter}px`, }, - /** Standard paragraph text — platform body copy (`--text-body`, normal weight) */ + /** Standard paragraph text */ paragraph: { - fontSize: typography.fontSize.md, - lineHeight: typography.lineHeight.body, - color: colors.textBody, - fontWeight: fontWeight.normal, - fontFamily: typography.fontFamily, + ...bodyText, margin: `${spacing.paragraphGap}px 0`, }, /** - * The opening line of an email body. Identical to {@link paragraph} but flush - * to the top, so it sits at a fixed distance below the logo instead of - * inheriting the paragraph gap. Half the templates spelled this out inline and - * half forgot it — always use the token. + * The opening line of an email body — flush to the top, so it sits a fixed + * distance below the logo instead of inheriting the paragraph gap. */ greeting: { - fontSize: typography.fontSize.md, - lineHeight: typography.lineHeight.body, - color: colors.textBody, - fontWeight: fontWeight.normal, - fontFamily: typography.fontFamily, + ...bodyText, margin: `0 0 ${spacing.paragraphGap}px 0`, }, - /** Inline emphasized label (e.g., "Platform:", "Time:") */ - label: { - fontSize: typography.fontSize.md, - lineHeight: typography.lineHeight.body, - color: colors.textPrimary, - fontWeight: fontWeight.semibold, - fontFamily: typography.fontFamily, - margin: 0, - display: 'inline', - }, - /** * Primary CTA — the platform's primary Chip, transcribed for email: * `chipPrimaryFillTokens` fill (`--text-primary`), `chipGeometryClass` @@ -229,7 +208,7 @@ export const baseStyles = { fontFamily: typography.fontFamily, }, - /** Footer text style */ + /** Footer text style — used inside the footer's own centered table cells */ footerText: { fontSize: typography.fontSize.caption, lineHeight: typography.lineHeight.caption, @@ -238,6 +217,21 @@ export const baseStyles = { margin: '0 0 10px 0', }, + /** + * The closing fine-print line inside the card (who this was sent to, when it + * fires again). Same ramp as {@link footerText}, but left-aligned — the card + * is left-aligned while the footer's cells are not. Every template spelled + * this out as a spread override; use the token. + */ + footnote: { + fontSize: typography.fontSize.caption, + lineHeight: typography.lineHeight.caption, + color: colors.textMuted, + fontFamily: typography.fontFamily, + margin: '0 0 10px 0', + textAlign: 'left' as const, + }, + /** Code/OTP container */ codeContainer: { margin: '12px 0', @@ -258,32 +252,17 @@ export const baseStyles = { margin: 0, }, - /** Code block text (for JSON/code display) */ - codeBlock: { - fontSize: typography.fontSize.caption, - lineHeight: typography.lineHeight.caption, - color: colors.textBody, - fontFamily: 'monospace', - whiteSpace: 'pre-wrap' as const, - wordWrap: 'break-word' as const, - margin: 0, - }, - /** Highlighted info box (e.g., "What you get with Pro") */ infoBox: { + ...boxGeometry, backgroundColor: colors.surfaceSubtle, - padding: '16px 18px', - borderRadius: RADIUS, - margin: '16px 0', }, - /** Error-state variant of {@link infoBox} — same geometry, error fill and border */ + /** Error-state variant of {@link infoBox} */ errorBox: { + ...boxGeometry, backgroundColor: colors.errorBg, border: `1px solid ${colors.errorBorder}`, - padding: '16px 18px', - borderRadius: RADIUS, - margin: '16px 0', }, /** Info box title */ @@ -325,22 +304,6 @@ export const baseStyles = { margin: '4px 0 0 0', }, - /** Section borders - decorative accent line */ - sectionsBorders: { - width: '100%', - display: 'flex', - }, - - sectionBorder: { - borderBottom: `1px solid ${colors.border}`, - width: '249px', - }, - - sectionCenter: { - borderBottom: `1px solid ${colors.brandTertiary}`, - width: '102px', - }, - /** Spacer row for vertical spacing in tables */ spacer: { border: 0, @@ -359,27 +322,12 @@ export const baseStyles = { lineHeight: '1px', width: `${spacing.gutter}px`, }, - - /** Info row (e.g., Platform, Device location, Time) */ - infoRow: { - fontSize: typography.fontSize.md, - lineHeight: typography.lineHeight.body, - color: colors.textBody, - fontFamily: typography.fontFamily, - margin: '8px 0', - }, } -/** - * Styles for plain personal emails (no branding, no EmailLayout). - * - * The system font stack is deliberate — these read as a message typed by a - * person, so they must NOT carry the brand face. Everything else still resolves - * through the shared tokens. - */ +/** Styles for plain personal emails (no branding, no EmailLayout). */ export const plainEmailStyles = { body: { - fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', + fontFamily: typography.systemFontFamily, backgroundColor: colors.bgCard, margin: '0', padding: '0', diff --git a/apps/sim/components/emails/auth/existing-account-email.tsx b/apps/sim/components/emails/auth/existing-account-email.tsx index 637ab7df66f..8c13a578d57 100644 --- a/apps/sim/components/emails/auth/existing-account-email.tsx +++ b/apps/sim/components/emails/auth/existing-account-email.tsx @@ -1,6 +1,6 @@ -import { Link, Text } from '@react-email/components' +import { Text } from '@react-email/components' import { baseStyles } from '@/components/emails/_styles' -import { EmailLayout } from '@/components/emails/components' +import { EmailButton, EmailLayout } from '@/components/emails/components' import { getBaseUrl } from '@/lib/core/utils/urls' import { getBrandConfig } from '@/ee/whitelabeling' @@ -30,13 +30,11 @@ export function ExistingAccountEmail({ username = '' }: ExistingAccountEmailProp forgotten it.
- - Sign In - + Sign In
- + If this wasn't you, no action is needed — no account was created or changed. diff --git a/apps/sim/components/emails/auth/onboarding-followup-email.tsx b/apps/sim/components/emails/auth/onboarding-followup-email.tsx index 042b2fd29a1..48564ba1c4e 100644 --- a/apps/sim/components/emails/auth/onboarding-followup-email.tsx +++ b/apps/sim/components/emails/auth/onboarding-followup-email.tsx @@ -1,11 +1,14 @@ import { Body, Head, Html, Preview, Text } from '@react-email/components' import { plainEmailStyles as styles } from '@/components/emails/_styles' +import { getBrandConfig } from '@/ee/whitelabeling' interface OnboardingFollowupEmailProps { userName?: string } export function OnboardingFollowupEmail({ userName }: OnboardingFollowupEmailProps) { + const brand = getBrandConfig() + return ( @@ -14,7 +17,7 @@ export function OnboardingFollowupEmail({ userName }: OnboardingFollowupEmailPro
{userName ? `Hey ${userName},` : 'Hey,'} - It's been a few days since you signed up. I hope you're enjoying Sim! + It's been a few days since you signed up. I hope you're enjoying {brand.name}! I'd love to know — what did you expect when you signed up vs. what did you get? @@ -27,7 +30,7 @@ export function OnboardingFollowupEmail({ userName }: OnboardingFollowupEmailPro
Emir
- Founder, Sim + Founder, {brand.name}
diff --git a/apps/sim/components/emails/auth/otp-verification-email.tsx b/apps/sim/components/emails/auth/otp-verification-email.tsx index 5b60caf6854..6f4170286a9 100644 --- a/apps/sim/components/emails/auth/otp-verification-email.tsx +++ b/apps/sim/components/emails/auth/otp-verification-email.tsx @@ -45,10 +45,9 @@ export function OTPVerificationEmail({ This code will expire in 15 minutes. - {/* Divider */}
- + Do not share this code with anyone. If you didn't request this code, you can safely ignore this email. diff --git a/apps/sim/components/emails/auth/reset-password-email.tsx b/apps/sim/components/emails/auth/reset-password-email.tsx index cfdf82ff297..ed0b5f27861 100644 --- a/apps/sim/components/emails/auth/reset-password-email.tsx +++ b/apps/sim/components/emails/auth/reset-password-email.tsx @@ -1,6 +1,6 @@ -import { Link, Text } from '@react-email/components' +import { Text } from '@react-email/components' import { baseStyles } from '@/components/emails/_styles' -import { EmailLayout } from '@/components/emails/components' +import { EmailButton, EmailLayout } from '@/components/emails/components' import { getBrandConfig } from '@/ee/whitelabeling' interface ResetPasswordEmailProps { @@ -19,14 +19,11 @@ export function ResetPasswordEmail({ username = '', resetLink = '' }: ResetPassw password. - - Reset Password - + Reset Password - {/* Divider */}
- + If you didn't request this, you can ignore this email. Link expires in 24 hours. diff --git a/apps/sim/components/emails/auth/welcome-email.tsx b/apps/sim/components/emails/auth/welcome-email.tsx index 4fbe5a88c8c..c5050b7128e 100644 --- a/apps/sim/components/emails/auth/welcome-email.tsx +++ b/apps/sim/components/emails/auth/welcome-email.tsx @@ -1,6 +1,6 @@ import { Link, Text } from '@react-email/components' import { baseStyles } from '@/components/emails/_styles' -import { EmailLayout } from '@/components/emails/components' +import { EmailButton, EmailLayout } from '@/components/emails/components' import { getBaseUrl } from '@/lib/core/utils/urls' import { getBrandConfig } from '@/ee/whitelabeling' @@ -20,9 +20,7 @@ export function WelcomeEmail({ userName }: WelcomeEmailProps) { workflows in minutes. - - Get Started - + Get Started If you have any questions or feedback, just reply to this email. I read every message! @@ -38,10 +36,9 @@ export function WelcomeEmail({ userName }: WelcomeEmailProps) { - Emir, co-founder of {brand.name} - {/* Divider */}
- + You're on the Community plan with 1,000 credits to get started. diff --git a/apps/sim/components/emails/billing/abandoned-checkout-email.tsx b/apps/sim/components/emails/billing/abandoned-checkout-email.tsx index adc3bb1379e..2a0ab9462e2 100644 --- a/apps/sim/components/emails/billing/abandoned-checkout-email.tsx +++ b/apps/sim/components/emails/billing/abandoned-checkout-email.tsx @@ -1,11 +1,14 @@ import { Body, Head, Html, Preview, Text } from '@react-email/components' import { plainEmailStyles as styles } from '@/components/emails/_styles' +import { getBrandConfig } from '@/ee/whitelabeling' interface AbandonedCheckoutEmailProps { userName?: string } export function AbandonedCheckoutEmail({ userName }: AbandonedCheckoutEmailProps) { + const brand = getBrandConfig() + return ( @@ -14,7 +17,8 @@ export function AbandonedCheckoutEmail({ userName }: AbandonedCheckoutEmailProps
{userName ? `Hi ${userName},` : 'Hi,'} - I saw that you tried to upgrade your Sim plan but didn't end up completing it. + I saw that you tried to upgrade your {brand.name} plan but didn't end up completing + it. Did you run into an issue, or did you have a question? Here to help. @@ -22,7 +26,7 @@ export function AbandonedCheckoutEmail({ userName }: AbandonedCheckoutEmailProps — Emir
- Founder, Sim + Founder, {brand.name}
diff --git a/apps/sim/components/emails/billing/credit-purchase-email.tsx b/apps/sim/components/emails/billing/credit-purchase-email.tsx index f6f42fc0654..55b14677dd3 100644 --- a/apps/sim/components/emails/billing/credit-purchase-email.tsx +++ b/apps/sim/components/emails/billing/credit-purchase-email.tsx @@ -1,6 +1,6 @@ -import { Link, Section, Text } from '@react-email/components' +import { Section, Text } from '@react-email/components' import { baseStyles } from '@/components/emails/_styles' -import { EmailLayout } from '@/components/emails/components' +import { EmailButton, EmailLayout, EmailStrong } from '@/components/emails/components' import { dollarsToCredits } from '@/lib/billing/credits/conversion' import { getBaseUrl } from '@/lib/core/utils/urls' import { getBrandConfig } from '@/ee/whitelabeling' @@ -27,8 +27,9 @@ export function CreditPurchaseEmail({ {userName ? `Hi ${userName},` : 'Hi,'} - Your credit purchase of {dollarsToCredits(amount).toLocaleString()} credits{' '} - has been confirmed. + Your credit purchase of{' '} + {dollarsToCredits(amount).toLocaleString()} credits has been + confirmed.
@@ -46,14 +47,11 @@ export function CreditPurchaseEmail({ Credits are applied automatically to your workflow executions. - - View Dashboard - + View Dashboard - {/* Divider */}
- + Purchased on {purchaseDate.toLocaleDateString()}. View balance in Settings → Subscription. diff --git a/apps/sim/components/emails/billing/credits-exhausted-email.tsx b/apps/sim/components/emails/billing/credits-exhausted-email.tsx index 8362efa52ba..c81232b8d7a 100644 --- a/apps/sim/components/emails/billing/credits-exhausted-email.tsx +++ b/apps/sim/components/emails/billing/credits-exhausted-email.tsx @@ -1,7 +1,7 @@ -import { Link, Text } from '@react-email/components' +import { Text } from '@react-email/components' import { baseStyles } from '@/components/emails/_styles' import { ProFeaturesBox } from '@/components/emails/billing/pro-features-box' -import { EmailLayout } from '@/components/emails/components' +import { EmailButton, EmailLayout, EmailStrong } from '@/components/emails/components' import { dollarsToCredits } from '@/lib/billing/credits/conversion' import { getBrandConfig } from '@/ee/whitelabeling' @@ -26,19 +26,17 @@ export function CreditsExhaustedEmail({ {userName ? `Hi ${userName},` : 'Hi,'} - You've used all {dollarsToCredits(limit).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. - - Upgrade to Pro - + Upgrade to Pro
- + One-time notification when free credits are exhausted. diff --git a/apps/sim/components/emails/billing/enterprise-subscription-email.tsx b/apps/sim/components/emails/billing/enterprise-subscription-email.tsx index 63eae26f0b5..e252e7839ae 100644 --- a/apps/sim/components/emails/billing/enterprise-subscription-email.tsx +++ b/apps/sim/components/emails/billing/enterprise-subscription-email.tsx @@ -1,6 +1,6 @@ -import { Link, Text } from '@react-email/components' +import { Link, Section, Text } from '@react-email/components' import { baseStyles } from '@/components/emails/_styles' -import { EmailLayout } from '@/components/emails/components' +import { EmailButton, EmailLayout, EmailStrong } from '@/components/emails/components' import { getBaseUrl } from '@/lib/core/utils/urls' import { getBrandConfig } from '@/ee/whitelabeling' @@ -24,24 +24,23 @@ export function EnterpriseSubscriptionEmail({ > Hello {userName}, - Your Enterprise Plan is now active. You have full access to advanced - features and increased capacity for your workflows. + Your Enterprise Plan is now active. You have full access to + advanced features and increased capacity for your workflows. - - Open {brand.name} - +
+ Next steps + + • Invite teammates to your organization +
• Start building your workflows +
+
- - Next steps: -
• Invite teammates to your organization -
• Start building your workflows -
+ Open {brand.name} - {/* Divider */}
- + Questions? Reply to this email or contact us at{' '} {brand.supportEmail} 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 d10fcfaa706..f34e15aeba0 100644 --- a/apps/sim/components/emails/billing/free-tier-upgrade-email.tsx +++ b/apps/sim/components/emails/billing/free-tier-upgrade-email.tsx @@ -1,7 +1,7 @@ -import { Link, Text } from '@react-email/components' +import { Text } from '@react-email/components' import { baseStyles } from '@/components/emails/_styles' import { ProFeaturesBox } from '@/components/emails/billing/pro-features-box' -import { EmailLayout } from '@/components/emails/components' +import { EmailButton, EmailLayout, EmailStrong } from '@/components/emails/components' import { dollarsToCredits } from '@/lib/billing/credits/conversion' import { getBrandConfig } from '@/ee/whitelabeling' @@ -29,23 +29,18 @@ export function FreeTierUpgradeEmail({ {userName ? `Hi ${userName},` : 'Hi,'} - You've used {dollarsToCredits(currentUsage).toLocaleString()} of your{' '} - {dollarsToCredits(limit).toLocaleString()} free credits ({percentUsed}%). - Upgrade to Pro to keep building without interruption. + You've used {dollarsToCredits(currentUsage).toLocaleString()} of + your {dollarsToCredits(limit).toLocaleString()} free credits ( + {percentUsed}%). Upgrade to Pro to keep building without interruption. - - Upgrade to Pro - + Upgrade to Pro - {/* Divider */}
- - One-time notification at 80% usage. - + One-time notification at 80% usage. ) } diff --git a/apps/sim/components/emails/billing/limit-threshold-email.tsx b/apps/sim/components/emails/billing/limit-threshold-email.tsx index 5a898f421ea..f8e1a3d913f 100644 --- a/apps/sim/components/emails/billing/limit-threshold-email.tsx +++ b/apps/sim/components/emails/billing/limit-threshold-email.tsx @@ -1,6 +1,6 @@ -import { Link, Section, Text } from '@react-email/components' +import { Section, Text } from '@react-email/components' import { baseStyles } from '@/components/emails/_styles' -import { EmailLayout } from '@/components/emails/components' +import { EmailButton, EmailLayout } from '@/components/emails/components' import { UPGRADE_REASON_COPY, type UpgradeReason } from '@/lib/billing/upgrade-reasons' import { getBrandConfig } from '@/ee/whitelabeling' @@ -52,14 +52,11 @@ export function LimitThresholdEmail({
- - Upgrade - + Upgrade - {/* Divider */}
- + {kind === 'reached' ? 'One-time notification at 100% usage.' : 'One-time notification at 80% usage.'} diff --git a/apps/sim/components/emails/billing/payment-failed-email.tsx b/apps/sim/components/emails/billing/payment-failed-email.tsx index dec36a0dbb8..0e76c93566b 100644 --- a/apps/sim/components/emails/billing/payment-failed-email.tsx +++ b/apps/sim/components/emails/billing/payment-failed-email.tsx @@ -1,6 +1,6 @@ import { Link, Section, Text } from '@react-email/components' import { baseStyles, colors, fontWeight } from '@/components/emails/_styles' -import { EmailLayout } from '@/components/emails/components' +import { EmailButton, EmailLayout } from '@/components/emails/components' import { getBrandConfig } from '@/ee/whitelabeling' interface PaymentFailedEmailProps { @@ -61,14 +61,11 @@ export function PaymentFailedEmail({ - - Update Payment Method - + Update Payment Method - {/* Divider */}
- + Common issues: expired card, insufficient funds, or incorrect billing info. Need help?{' '} {brand.supportEmail} diff --git a/apps/sim/components/emails/billing/plan-welcome-email.tsx b/apps/sim/components/emails/billing/plan-welcome-email.tsx index 1b1363d5c5f..073c359dff8 100644 --- a/apps/sim/components/emails/billing/plan-welcome-email.tsx +++ b/apps/sim/components/emails/billing/plan-welcome-email.tsx @@ -1,6 +1,6 @@ import { Link, Text } from '@react-email/components' import { baseStyles } from '@/components/emails/_styles' -import { EmailLayout } from '@/components/emails/components' +import { EmailButton, EmailLayout, EmailStrong } from '@/components/emails/components' import { getBaseUrl } from '@/lib/core/utils/urls' import { getBrandConfig } from '@/ee/whitelabeling' @@ -21,13 +21,11 @@ export function PlanWelcomeEmail({ planName, userName, loginLink }: PlanWelcomeE {userName ? `Hi ${userName},` : 'Hi,'} - Welcome to {planName}! You're all set to build, test, and scale your - workflows. + Welcome to {planName}! You're all set to build, test, and scale + your workflows. - - Open {brand.name} - + Open {brand.name} Want help getting started?{' '} @@ -37,12 +35,9 @@ export function PlanWelcomeEmail({ planName, userName, loginLink }: PlanWelcomeE with our team. - {/* Divider */}
- - Manage your subscription in Settings → Subscription. - + Manage your subscription in Settings → Subscription. ) } diff --git a/apps/sim/components/emails/billing/pro-features-box.tsx b/apps/sim/components/emails/billing/pro-features-box.tsx index 9f2489ed9c4..58671fa82af 100644 --- a/apps/sim/components/emails/billing/pro-features-box.tsx +++ b/apps/sim/components/emails/billing/pro-features-box.tsx @@ -1,14 +1,20 @@ import { Section, Text } from '@react-email/components' -import { baseStyles, colors, fontWeight, typography } from '@/components/emails/_styles' +import { baseStyles, colors, fontWeight } from '@/components/emails/_styles' import { proFeatures } from '@/components/emails/billing/constants' +const CELL = { ...baseStyles.infoBoxList, padding: '4px 0' } +const LABEL_CELL = { + ...CELL, + fontWeight: fontWeight.semibold, + color: colors.textPrimary, + width: '45%', +} + /** - * The "Pro includes" panel shared by the two free-tier upgrade prompts - * ({@link CreditsExhaustedEmail}, {@link FreeTierUpgradeEmail}). + * The "Pro includes" panel shared by the two free-tier upgrade prompts. * - * A plain `infoBox` with a two-column table body — the table is what keeps the - * label and its qualifier on one row across email clients, which a list cannot - * do reliably. + * The two-column table is what keeps a feature and its qualifier on one row + * across email clients, which a list cannot do reliably. */ export function ProFeaturesBox() { return ( @@ -18,30 +24,8 @@ export function ProFeaturesBox() { {proFeatures.map((feature) => ( - - {feature.label} - - - {feature.desc} - + {feature.label} + {feature.desc} ))} diff --git a/apps/sim/components/emails/billing/usage-limit-reached-email.tsx b/apps/sim/components/emails/billing/usage-limit-reached-email.tsx index 56f5485e2c8..aa54b8272ff 100644 --- a/apps/sim/components/emails/billing/usage-limit-reached-email.tsx +++ b/apps/sim/components/emails/billing/usage-limit-reached-email.tsx @@ -1,6 +1,6 @@ -import { Link, Section, Text } from '@react-email/components' +import { Section, Text } from '@react-email/components' import { baseStyles } from '@/components/emails/_styles' -import { EmailLayout } from '@/components/emails/components' +import { EmailButton, EmailLayout } from '@/components/emails/components' import { dollarsToCredits } from '@/lib/billing/credits/conversion' import { getBrandConfig } from '@/ee/whitelabeling' @@ -57,16 +57,13 @@ export function UsageLimitReachedEmail({ : 'Raise your usage limit in billing settings, or upgrade your plan, to resume.'} - - - {isOrganization ? 'Raise Organization Limit' : 'Raise Usage Limit'} - - + + {isOrganization ? 'Raise Organization Limit' : 'Raise Usage Limit'} + - {/* Divider */}
- + Sent to the people who manage billing for this account. diff --git a/apps/sim/components/emails/billing/usage-threshold-email.tsx b/apps/sim/components/emails/billing/usage-threshold-email.tsx index 0db04ac687a..0157ea251e6 100644 --- a/apps/sim/components/emails/billing/usage-threshold-email.tsx +++ b/apps/sim/components/emails/billing/usage-threshold-email.tsx @@ -1,6 +1,6 @@ -import { Link, Section, Text } from '@react-email/components' +import { Section, Text } from '@react-email/components' import { baseStyles } from '@/components/emails/_styles' -import { EmailLayout } from '@/components/emails/components' +import { EmailButton, EmailLayout } from '@/components/emails/components' import { dollarsToCredits } from '@/lib/billing/credits/conversion' import { getBrandConfig } from '@/ee/whitelabeling' @@ -45,16 +45,11 @@ export function UsageThresholdEmail({ To avoid interruptions, consider increasing your monthly limit. - - Review Limits - + Review Limits - {/* Divider */}
- - One-time notification at 80% usage. - + One-time notification at 80% usage. ) } diff --git a/apps/sim/components/emails/components/email-button.tsx b/apps/sim/components/emails/components/email-button.tsx new file mode 100644 index 00000000000..c3bc377bae8 --- /dev/null +++ b/apps/sim/components/emails/components/email-button.tsx @@ -0,0 +1,28 @@ +import { Link, Text } from '@react-email/components' +import { baseStyles } from '@/components/emails/_styles' + +/** `Link` renders an underline by default; the pill owns its own chrome. */ +const RESET_LINK_STYLE = { textDecoration: 'none' } as const + +interface EmailButtonProps { + href: string + children: React.ReactNode +} + +/** + * The primary CTA pill — a `Link` wrapping a `Text` so the tappable area covers + * the whole pill in clients that ignore padding on an anchor. + * + * Every template repeated this three-node shape verbatim, including the + * underline reset; the styling lives in {@link baseStyles.button}, which + * transcribes the platform's primary Chip. + */ +export function EmailButton({ href, children }: EmailButtonProps) { + return ( + + {children} + + ) +} + +export default EmailButton diff --git a/apps/sim/components/emails/components/email-footer.tsx b/apps/sim/components/emails/components/email-footer.tsx index 3b2fe387fdd..f03a86b222e 100644 --- a/apps/sim/components/emails/components/email-footer.tsx +++ b/apps/sim/components/emails/components/email-footer.tsx @@ -13,9 +13,6 @@ import { getBrandConfig } from '@/ee/whitelabeling' */ const SOCIAL_ICON_SIZE = 20 -/** Gap between social marks. Halved on each side, so the row reads evenly. */ -const SOCIAL_ICON_GAP = 8 - /** * `display: block` removes the 2–3px gap Outlook adds under inline images; * `border: 0` prevents the blue link border older Outlook draws around a linked @@ -23,6 +20,9 @@ const SOCIAL_ICON_GAP = 8 */ const socialIconStyle = { display: 'block' as const, border: 0 } +/** Trailing gap only, so the row starts flush with the gutter. */ +const SOCIAL_CELL_STYLE = { paddingRight: 16 } as const + const SOCIAL_LINKS = [ { path: 'x', label: 'X', icon: 'x-icon.png' }, { path: 'linkedin', label: 'LinkedIn', icon: 'linkedin-icon.png' }, @@ -56,8 +56,6 @@ export function EmailFooter({ const brand = getBrandConfig() const isWhitelabeled = brand.isWhitelabeled - const footerLinkStyle = baseStyles.footerLink - return (
- {SOCIAL_LINKS.map(({ path, label, icon }, index) => ( - + {SOCIAL_LINKS.map(({ path, label, icon }) => ( + )} - {/* Contact row */}   @@ -164,7 +154,7 @@ export function EmailFooter({ target="_blank", which on a mailto: opens a blank tab beside the compose window in most webmail clients. */} - + {brand.supportEmail} @@ -179,7 +169,6 @@ export function EmailFooter({ - {/* Message ID row (optional) */} {messageId && ( <> @@ -201,17 +190,24 @@ export function EmailFooter({ )} - {/* Links row */}   - + Privacy Policy {' '} •{' '} - + Terms of Service {showUnsubscribe && ( @@ -220,7 +216,7 @@ export function EmailFooter({ •{' '} Unsubscribe @@ -233,7 +229,6 @@ export function EmailFooter({ - {/* Copyright row */}   diff --git a/apps/sim/components/emails/components/email-layout.tsx b/apps/sim/components/emails/components/email-layout.tsx index d7710800061..253f7512055 100644 --- a/apps/sim/components/emails/components/email-layout.tsx +++ b/apps/sim/components/emails/components/email-layout.tsx @@ -5,16 +5,14 @@ import { getBaseUrl } from '@/lib/core/utils/urls' import { getBrandConfig } from '@/ee/whitelabeling' /** - * Wordmark display size. `wordmark.png` is 272×164, so these are exactly 1/4 of - * its intrinsic size — the asset is a 4x source and must stay that way, since - * email clients do no responsive image selection. Changing one of these without - * the other distorts the mark. + * Wordmark display size — exactly 1/4 of `wordmark.png`'s intrinsic 272×164. + * The asset is a 4x source and must stay that way, since email clients do no + * responsive image selection; changing one dimension alone distorts the mark. */ -const WORDMARK_HEIGHT = 41 -const WORDMARK_WIDTH = 68 +const WORDMARK_SIZE = { height: '41', width: '68' } as const /** Whitelabeled logos are arbitrary aspect ratios, so only height is pinned. */ -const CUSTOM_LOGO_HEIGHT = 34 +const CUSTOM_LOGO_SIZE = { height: '34' } as const interface EmailLayoutProps { /** Preview text shown in email client list view */ @@ -67,25 +65,19 @@ export function EmailLayout({ {preview} - {/* Main card container */} - {/* Header with logo */}
{brand.name}
- {/* Content */}
{children}
- {/* Footer in gray section */} {!hideFooter && } diff --git a/apps/sim/components/emails/components/email-strong.tsx b/apps/sim/components/emails/components/email-strong.tsx new file mode 100644 index 00000000000..5b169cbb3d9 --- /dev/null +++ b/apps/sim/components/emails/components/email-strong.tsx @@ -0,0 +1,21 @@ +import { fontWeight } from '@/components/emails/_styles' + +const STRONG_STYLE = { fontWeight: fontWeight.semibold } as const + +interface EmailStrongProps { + children: React.ReactNode +} + +/** + * Inline emphasis inside body copy. + * + * A bare `strong` element inherits the client's UA weight of 700, which is off + * the platform's three-step scale (400/500/600) and reads noticeably heavier + * than anything in the app. This pins it to semibold while keeping the element + * itself, so the emphasis stays semantic for screen readers. + */ +export function EmailStrong({ children }: EmailStrongProps) { + return {children} +} + +export default EmailStrong diff --git a/apps/sim/components/emails/components/index.ts b/apps/sim/components/emails/components/index.ts index d7c1d712af8..560699abc42 100644 --- a/apps/sim/components/emails/components/index.ts +++ b/apps/sim/components/emails/components/index.ts @@ -1,2 +1,4 @@ +export { EmailButton } from './email-button' export { EmailFooter } from './email-footer' export { EmailLayout } from './email-layout' +export { EmailStrong } from './email-strong' diff --git a/apps/sim/components/emails/invitations/batch-invitation-email.tsx b/apps/sim/components/emails/invitations/batch-invitation-email.tsx index a5989707d44..9f7c7c5d0e1 100644 --- a/apps/sim/components/emails/invitations/batch-invitation-email.tsx +++ b/apps/sim/components/emails/invitations/batch-invitation-email.tsx @@ -1,6 +1,6 @@ -import { Link, Text } from '@react-email/components' +import { Section, Text } from '@react-email/components' import { baseStyles } from '@/components/emails/_styles' -import { EmailLayout } from '@/components/emails/components' +import { EmailButton, EmailLayout, EmailStrong } from '@/components/emails/components' import { getBrandConfig } from '@/ee/whitelabeling' interface WorkspaceInvitation { @@ -60,45 +60,39 @@ export function BatchInvitationEmail({ > Hello, - {inviterName} has invited you to join {organizationName}{' '} - on {brand.name}. + {inviterName} has invited you to join{' '} + {organizationName} on {brand.name}. - {/* Team Role Information */} - - Team Role: {getRoleLabel(organizationRole)} - - - {organizationRole === 'admin' - ? "As a Team Admin, you'll be able to manage team members, billing, and workspace access." - : "As a Team Member, you'll have access to shared team billing and can be invited to workspaces."} - +
+ Team role + + {getRoleLabel(organizationRole)} —{' '} + {organizationRole === 'admin' + ? 'you can manage team members, billing, and workspace access.' + : 'you have access to shared team billing and can be invited to workspaces.'} + +
- {/* Workspace Invitations */} {hasWorkspaces && ( - <> - - - Workspace Access ({workspaceInvitations.length} workspace - {workspaceInvitations.length !== 1 ? 's' : ''}): - +
+ + Workspace access ({workspaceInvitations.length} workspace + {workspaceInvitations.length !== 1 ? 's' : ''}) {workspaceInvitations.map((ws) => ( - - • {ws.workspaceName} - {getPermissionLabel(ws.permission)} + + {ws.workspaceName} — {getPermissionLabel(ws.permission)} ))} - +
)} - - Accept Invitation - + Accept Invitation - {/* Divider */}
- + Invitation expires in 7 days. If unexpected, you can ignore this email. diff --git a/apps/sim/components/emails/invitations/invitation-email.tsx b/apps/sim/components/emails/invitations/invitation-email.tsx index 118cc6dc56c..11dccce7995 100644 --- a/apps/sim/components/emails/invitations/invitation-email.tsx +++ b/apps/sim/components/emails/invitations/invitation-email.tsx @@ -1,7 +1,7 @@ -import { Link, Text } from '@react-email/components' +import { Text } from '@react-email/components' import { createLogger } from '@sim/logger' import { baseStyles } from '@/components/emails/_styles' -import { EmailLayout } from '@/components/emails/components' +import { EmailButton, EmailLayout, EmailStrong } from '@/components/emails/components' import { getBaseUrl } from '@/lib/core/utils/urls' import { getBrandConfig } from '@/ee/whitelabeling' @@ -42,18 +42,15 @@ export function InvitationEmail({ > Hello, - {inviterName} invited you to join {organizationName} on{' '} - {brand.name}. + {inviterName} invited you to join{' '} + {organizationName} on {brand.name}. - - Accept Invitation - + Accept Invitation - {/* Divider */}
- + Invitation expires in 48 hours. If unexpected, you can ignore this email. diff --git a/apps/sim/components/emails/invitations/workspace-added-email.tsx b/apps/sim/components/emails/invitations/workspace-added-email.tsx index a23a6e51114..4c22c650c0a 100644 --- a/apps/sim/components/emails/invitations/workspace-added-email.tsx +++ b/apps/sim/components/emails/invitations/workspace-added-email.tsx @@ -1,6 +1,6 @@ -import { Link, Text } from '@react-email/components' +import { Text } from '@react-email/components' import { baseStyles } from '@/components/emails/_styles' -import { EmailLayout } from '@/components/emails/components' +import { EmailButton, EmailLayout, EmailStrong } from '@/components/emails/components' import { getBrandConfig } from '@/ee/whitelabeling' interface WorkspaceAddedEmailProps { @@ -24,19 +24,15 @@ export function WorkspaceAddedEmail({ Hello, - {inviterName} added you to the {workspaceName} workspace - on {brand.name}. + {inviterName} added you to the{' '} + {workspaceName} workspace on {brand.name}. - - Open workspace - + Open workspace
- - If this was unexpected, contact a workspace admin. - + If this was unexpected, contact a workspace admin. ) } diff --git a/apps/sim/components/emails/invitations/workspace-invitation-email.tsx b/apps/sim/components/emails/invitations/workspace-invitation-email.tsx index bf73c26b354..5ad0a4156cd 100644 --- a/apps/sim/components/emails/invitations/workspace-invitation-email.tsx +++ b/apps/sim/components/emails/invitations/workspace-invitation-email.tsx @@ -1,6 +1,6 @@ -import { Link, Text } from '@react-email/components' +import { Text } from '@react-email/components' import { baseStyles } from '@/components/emails/_styles' -import { EmailLayout } from '@/components/emails/components' +import { EmailButton, EmailLayout, EmailStrong } from '@/components/emails/components' import { getBrandConfig } from '@/ee/whitelabeling' interface WorkspaceInvitationEmailProps { @@ -25,7 +25,7 @@ export function WorkspaceInvitationEmail({ Hello, - {inviterName} invited you to join the{' '} + {inviterName} invited you to join the{' '} {workspaceNames.map((name, index) => ( {index > 0 && @@ -34,19 +34,17 @@ export function WorkspaceInvitationEmail({ ? ', and ' : ' and ' : ', ')} - {name} + {name} ))}{' '} {isMultiple ? 'workspaces' : 'workspace'} on {brand.name}. - - Accept Invitation - + Accept Invitation
- + Invitation expires in 7 days. If unexpected, you can ignore this email. diff --git a/apps/sim/components/emails/notifications/schedule-disabled-email.tsx b/apps/sim/components/emails/notifications/schedule-disabled-email.tsx index ac022eb84b0..8c8cb60ae40 100644 --- a/apps/sim/components/emails/notifications/schedule-disabled-email.tsx +++ b/apps/sim/components/emails/notifications/schedule-disabled-email.tsx @@ -1,6 +1,6 @@ -import { Link, Section, Text } from '@react-email/components' +import { Section, Text } from '@react-email/components' import { baseStyles } from '@/components/emails/_styles' -import { EmailLayout } from '@/components/emails/components' +import { EmailButton, EmailLayout } from '@/components/emails/components' import { SCHEDULE_DISABLE_REASON_COPY, type ScheduleDisableReason, @@ -51,16 +51,11 @@ export function ScheduleDisabledEmail({ {reasonCopy}
- {manageLink ? ( - - Open workflow - - ) : null} + {manageLink ? Open workflow : null} - {/* Divider */}
- + Sent to workspace admins and the person who created this schedule. diff --git a/apps/sim/components/emails/support/help-confirmation-email.tsx b/apps/sim/components/emails/support/help-confirmation-email.tsx index 9c12e35f2ce..ebf0ed68e9c 100644 --- a/apps/sim/components/emails/support/help-confirmation-email.tsx +++ b/apps/sim/components/emails/support/help-confirmation-email.tsx @@ -1,7 +1,7 @@ import { Text } from '@react-email/components' import { format } from 'date-fns' import { baseStyles } from '@/components/emails/_styles' -import { EmailLayout } from '@/components/emails/components' +import { EmailLayout, EmailStrong } from '@/components/emails/components' interface HelpConfirmationEmailProps { type?: 'bug' | 'feedback' | 'feature_request' | 'other' @@ -38,8 +38,8 @@ export function HelpConfirmationEmail({ > Hello, - We've received your {typeLabel.toLowerCase()} and will get back to you - shortly. + We've received your {typeLabel.toLowerCase()} and will get back + to you shortly. {attachmentCount > 0 && ( @@ -48,10 +48,9 @@ export function HelpConfirmationEmail({ )} - {/* Divider */}
- + Submitted on {format(submittedDate, 'MMMM do, yyyy')}. From 9e3ba41f8032c19eb3340611758808a00880395e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 9 Aug 2026 17:54:26 -0700 Subject: [PATCH 5/6] fix(emails): restore row spacing in the payment-failed details box --- .../emails/billing/payment-failed-email.tsx | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/apps/sim/components/emails/billing/payment-failed-email.tsx b/apps/sim/components/emails/billing/payment-failed-email.tsx index 0e76c93566b..ace471b9747 100644 --- a/apps/sim/components/emails/billing/payment-failed-email.tsx +++ b/apps/sim/components/emails/billing/payment-failed-email.tsx @@ -45,11 +45,21 @@ export function PaymentFailedEmail({
Payment Details - Amount due: ${amountDue.toFixed(2)} - {lastFourDigits && ( - Payment method: •••• {lastFourDigits} - )} - {failureReason && Reason: {failureReason}} + + Amount due: ${amountDue.toFixed(2)} + {lastFourDigits && ( + <> +
+ Payment method: •••• {lastFourDigits} + + )} + {failureReason && ( + <> +
+ Reason: {failureReason} + + )} +
From 9c2043f1a632a8d1443d7c557d9dd40d767dcc4f Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 9 Aug 2026 18:02:03 -0700 Subject: [PATCH 6/6] fix(emails): restore row spacing in the batch-invitation workspace list --- apps/sim/components/emails/_styles/base.ts | 7 ++++++- .../emails/invitations/batch-invitation-email.tsx | 14 +++++++++----- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/apps/sim/components/emails/_styles/base.ts b/apps/sim/components/emails/_styles/base.ts index 2559fbcc157..c9b51e90de4 100644 --- a/apps/sim/components/emails/_styles/base.ts +++ b/apps/sim/components/emails/_styles/base.ts @@ -275,7 +275,12 @@ export const baseStyles = { margin: '0 0 8px 0', }, - /** Info box list content */ + /** + * Info box body copy. + * + * `margin: 0` — so multi-row content must be ONE `Text` with `
` between + * rows, never several `Text` nodes, which would stack flush with no gap. + */ infoBoxList: { fontSize: typography.fontSize.md, lineHeight: '1.6', diff --git a/apps/sim/components/emails/invitations/batch-invitation-email.tsx b/apps/sim/components/emails/invitations/batch-invitation-email.tsx index 9f7c7c5d0e1..da9d3d57af7 100644 --- a/apps/sim/components/emails/invitations/batch-invitation-email.tsx +++ b/apps/sim/components/emails/invitations/batch-invitation-email.tsx @@ -1,3 +1,4 @@ +import { Fragment } from 'react' import { Section, Text } from '@react-email/components' import { baseStyles } from '@/components/emails/_styles' import { EmailButton, EmailLayout, EmailStrong } from '@/components/emails/components' @@ -80,11 +81,14 @@ export function BatchInvitationEmail({ Workspace access ({workspaceInvitations.length} workspace {workspaceInvitations.length !== 1 ? 's' : ''}) - {workspaceInvitations.map((ws) => ( - - {ws.workspaceName} — {getPermissionLabel(ws.permission)} - - ))} + + {workspaceInvitations.map((ws, index) => ( + + {index > 0 &&
} + {ws.workspaceName} — {getPermissionLabel(ws.permission)} +
+ ))} +
)}