diff --git a/apps/sim/app/api/emails/preview/route.ts b/apps/sim/app/api/emails/preview/route.ts index 77ec9673d1e..63cfb4994dd 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,8 +20,10 @@ import { renderUsageLimitReachedEmail, renderUsageThresholdEmail, renderWelcomeEmail, + 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' @@ -24,11 +31,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 +99,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 +187,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 +230,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 +254,29 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ` - Email Previews + + + Email Templates

Email Templates

+

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 2ff9fcc74ac..c9b51e90de4 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', - /** Secondary text — platform `--text-secondary` */ - textSecondary: '#525252', - /** Tertiary text — platform `--text-tertiary` */ - textTertiary: '#5c5c5c', - /** Muted text (footer) — platform `--text-muted` */ - textMuted: '#707070', - /** Brand primary — neutral by default, brand color when whitelabeled */ - brandPrimary: - isWhitelabeled && brand.theme?.primaryColor ? brand.theme.primaryColor : '#1a1a1a', + /** Body and value text — platform `--text-body` */ + textBody: '#434343', + /** Muted text (labels, footer) — platform `--text-muted` */ + textMuted: '#7a7a7a', /** 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. Enforced against the platform sources by + * `base.tokens.test.ts`. + */ 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. + */ 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", + /** + * 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: { - body: '16px', - small: '14px', caption: '12px', + sm: '14px', + base: '15px', + /** 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 + * scale — the app has no headline-numeral token because it has no surface + * that needs one. + */ + display: '24px', }, lineHeight: { body: '24px', @@ -60,19 +84,43 @@ 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, gutter: 40, - sectionGap: 20, paragraphGap: 12, - /** Logo width in pixels */ - logoWidth: 90, } -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, @@ -80,19 +128,13 @@ 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`, margin: '0 auto', backgroundColor: colors.bgCard, - border: `1px solid ${colors.divider}`, - borderRadius: '8px', + border: `1px solid ${colors.border}`, + borderRadius: RADIUS, overflow: 'hidden', }, @@ -109,35 +151,33 @@ export const baseStyles = { /** Standard paragraph text */ paragraph: { - fontSize: typography.fontSize.body, - lineHeight: typography.lineHeight.body, - color: colors.textSecondary, - fontWeight: 400, - fontFamily: typography.fontFamily, + ...bodyText, margin: `${spacing.paragraphGap}px 0`, }, - /** Bold label text (e.g., "Platform:", "Time:") */ - label: { - fontSize: typography.fontSize.body, - lineHeight: typography.lineHeight.body, - color: colors.textSecondary, - fontWeight: 'bold' as const, - fontFamily: typography.fontFamily, - margin: 0, - display: 'inline', + /** + * 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: { + ...bodyText, + margin: `0 0 ${spacing.paragraphGap}px 0`, }, - /** 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,25 +187,28 @@ 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 */ + /** Footer text style — used inside the footer's own centered table cells */ footerText: { fontSize: typography.fontSize.caption, lineHeight: typography.lineHeight.caption, @@ -174,78 +217,96 @@ 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', 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, margin: 0, }, - /** Code block text (for JSON/code display) */ - codeBlock: { - fontSize: typography.fontSize.caption, - lineHeight: typography.lineHeight.caption, - color: colors.textSecondary, - 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: '8px', - margin: '16px 0', + }, + + /** Error-state variant of {@link infoBox} */ + errorBox: { + ...boxGeometry, + backgroundColor: colors.errorBg, + border: `1px solid ${colors.errorBorder}`, }, /** 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', }, - /** 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.body, + fontSize: typography.fontSize.md, lineHeight: '1.6', - color: colors.textSecondary, + color: colors.textBody, fontFamily: typography.fontFamily, margin: 0, }, - /** Section borders - decorative accent line */ - sectionsBorders: { - width: '100%', - display: 'flex', - }, - - sectionBorder: { - borderBottom: `1px solid ${colors.divider}`, - width: '249px', + /** 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, }, - sectionCenter: { - borderBottom: `1px solid ${colors.brandTertiary}`, - width: '102px', + /** 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', }, /** Spacer row for vertical spacing in tables */ @@ -266,22 +327,13 @@ export const baseStyles = { lineHeight: '1px', width: `${spacing.gutter}px`, }, - - /** Info row (e.g., Platform, Device location, Time) */ - infoRow: { - fontSize: typography.fontSize.body, - lineHeight: typography.lineHeight.body, - color: colors.textSecondary, - fontFamily: typography.fontFamily, - margin: '8px 0', - }, } -/** Styles for plain personal emails (no branding, no EmailLayout) */ +/** Styles for plain personal emails (no branding, no EmailLayout). */ export const plainEmailStyles = { body: { - fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif', - backgroundColor: '#ffffff', + fontFamily: typography.systemFontFamily, + backgroundColor: colors.bgCard, margin: '0', padding: '0', }, @@ -291,9 +343,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..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' @@ -23,20 +23,18 @@ 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 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 e23e938c5ab..6f4170286a9 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} @@ -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 e86effe0fe8..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 { @@ -13,20 +13,17 @@ 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. - - 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 d7f5b8643e7..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' @@ -14,17 +14,13 @@ 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. - - Get Started - + Get Started If you have any questions or feedback, just reply to this email. I read every message! @@ -40,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 8242587a284..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 { baseStyles, colors } from '@/components/emails/_styles' -import { EmailLayout } from '@/components/emails/components' +import { Section, Text } from '@react-email/components' +import { baseStyles } from '@/components/emails/_styles' +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' @@ -25,55 +25,20 @@ export function CreditPurchaseEmail({ return ( - - {userName ? `Hi ${userName},` : 'Hi,'} - + {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.
- - Amount Added - - + Amount Added + {dollarsToCredits(amount).toLocaleString()} credits - - New Balance - - + New Balance + {dollarsToCredits(newBalance).toLocaleString()} credits
@@ -82,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 d505e9e0726..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, Section, Text } from '@react-email/components' -import { baseStyles, colors, typography } from '@/components/emails/_styles' -import { proFeatures } from '@/components/emails/billing/constants' -import { EmailLayout } from '@/components/emails/components' +import { Text } from '@react-email/components' +import { baseStyles } from '@/components/emails/_styles' +import { ProFeaturesBox } from '@/components/emails/billing/pro-features-box' +import { EmailButton, EmailLayout, EmailStrong } from '@/components/emails/components' import { dollarsToCredits } from '@/lib/billing/credits/conversion' import { getBrandConfig } from '@/ee/whitelabeling' @@ -23,76 +23,20 @@ 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. + 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 - + 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 722fa3edee7..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' @@ -22,26 +22,25 @@ 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. + 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 cb4a1960dca..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, Section, Text } from '@react-email/components' -import { baseStyles, colors, typography } from '@/components/emails/_styles' -import { proFeatures } from '@/components/emails/billing/constants' -import { EmailLayout } from '@/components/emails/components' +import { Text } from '@react-email/components' +import { baseStyles } from '@/components/emails/_styles' +import { ProFeaturesBox } from '@/components/emails/billing/pro-features-box' +import { EmailButton, EmailLayout, EmailStrong } from '@/components/emails/components' import { dollarsToCredits } from '@/lib/billing/credits/conversion' import { getBrandConfig } from '@/ee/whitelabeling' @@ -26,81 +26,21 @@ export function FreeTierUpgradeEmail({ return ( - - {userName ? `Hi ${userName},` : 'Hi,'} - + {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. - {/* Pro Features */} -
- - Pro includes - - - - {proFeatures.map((feature, i) => ( - - - - - ))} - -
- {feature.label} - - {feature.desc} -
-
+ - - 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 79f41df75e8..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' @@ -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,17 +52,11 @@ export function LimitThresholdEmail({
- {/* Divider */} -
- - - 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 8f93c4fb2e6..ace471b9747 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 } from '@/components/emails/_styles' -import { EmailLayout } from '@/components/emails/components' +import { baseStyles, colors, fontWeight } from '@/components/emails/_styles' +import { EmailButton, EmailLayout } from '@/components/emails/components' import { getBrandConfig } from '@/ee/whitelabeling' interface PaymentFailedEmailProps { @@ -26,15 +26,12 @@ export function PaymentFailedEmail({ return ( - - {userName ? `Hi ${userName},` : 'Hi,'} - + {userName ? `Hi ${userName},` : 'Hi,'} @@ -46,57 +43,39 @@ export function PaymentFailedEmail({ unexpected charges. To restore access immediately, please update your payment method. -
- - Payment Details - - +
+ Payment Details + Amount due: ${amountDue.toFixed(2)} + {lastFourDigits && ( + <> +
+ Payment method: •••• {lastFourDigits} + + )} + {failureReason && ( + <> +
+ Reason: {failureReason} + + )}
- {lastFourDigits && ( - - Payment method: •••• {lastFourDigits} - - )} - {failureReason && ( - Reason: {failureReason} - )}
- - Update Payment Method - - - {/* Divider */} -
- - What happens next? +
+ 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 +
+
- - • 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 -
+ 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 3df89a59465..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' @@ -19,17 +19,13 @@ 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. + Welcome to {planName}! You're all set to build, test, and scale + your workflows. - - Open {brand.name} - + Open {brand.name} Want help getting started?{' '} @@ -39,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 new file mode 100644 index 00000000000..58671fa82af --- /dev/null +++ b/apps/sim/components/emails/billing/pro-features-box.tsx @@ -0,0 +1,37 @@ +import { Section, Text } from '@react-email/components' +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. + * + * 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 ( +
+ 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..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' @@ -33,9 +33,7 @@ export function UsageLimitReachedEmail({ return ( - - {userName ? `Hi ${userName},` : 'Hi,'} - + {userName ? `Hi ${userName},` : 'Hi,'} {isOrganization @@ -53,25 +51,19 @@ export function UsageLimitReachedEmail({
- {/* Divider */} -
- {isOrganization ? 'Raise the organization usage limit in billing settings to resume.' : '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 4c4c6863e42..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' @@ -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,23 +41,15 @@ export function UsageThresholdEmail({ - {/* Divider */} -
- 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 ef85acd95b3..f03a86b222e 100644 --- a/apps/sim/components/emails/components/email-footer.tsx +++ b/apps/sim/components/emails/components/email-footer.tsx @@ -1,9 +1,35 @@ 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' +/** + * 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 + +/** + * `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 } + +/** 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' }, + { path: 'github', label: 'GitHub', icon: 'github-icon.png' }, + { path: 'slack', label: 'Slack', icon: 'slack-icon.png' }, +] as const + interface EmailFooterProps { baseUrl?: string messageId?: string @@ -30,23 +56,6 @@ export function EmailFooter({ const brand = getBrandConfig() const isWhitelabeled = brand.isWhitelabeled - const footerLinkStyle = { - color: colors.textMuted, - textDecoration: 'underline', - fontWeight: 'normal' as const, - fontFamily: typography.fontFamily, - } - - /** - * 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 }) => ( + + + {label} + + + ))} @@ -165,14 +143,18 @@ export function EmailFooter({ )} - {/* Contact row */}   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} @@ -187,7 +169,6 @@ export function EmailFooter({ - {/* Message ID row (optional) */} {messageId && ( <> @@ -209,30 +190,37 @@ export function EmailFooter({ )} - {/* Links row */}   - + Privacy Policy - {' '} + {' '} •{' '} - + Terms of Service - + {showUnsubscribe && ( <> {' '} •{' '} - Unsubscribe - + )} @@ -241,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 264fd177cdb..253f7512055 100644 --- a/apps/sim/components/emails/components/email-layout.tsx +++ b/apps/sim/components/emails/components/email-layout.tsx @@ -4,6 +4,16 @@ import { EmailFooter } from '@/components/emails/components/email-footer' import { getBaseUrl } from '@/lib/core/utils/urls' import { getBrandConfig } from '@/ee/whitelabeling' +/** + * 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_SIZE = { height: '41', width: '68' } as const + +/** Whitelabeled logos are arbitrary aspect ratios, so only height is pinned. */ +const CUSTOM_LOGO_SIZE = { height: '34' } as const + interface EmailLayoutProps { /** Preview text shown in email client list view */ preview: string @@ -35,9 +45,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. + */} {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 9bd6a31ca5c..da9d3d57af7 100644 --- a/apps/sim/components/emails/invitations/batch-invitation-email.tsx +++ b/apps/sim/components/emails/invitations/batch-invitation-email.tsx @@ -1,6 +1,7 @@ -import { Link, Text } from '@react-email/components' +import { Fragment } from 'react' +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 { @@ -58,47 +59,44 @@ 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}. + {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, index) => ( + + {index > 0 &&
} + {ws.workspaceName} — {getPermissionLabel(ws.permission)} +
+ ))}
- {workspaceInvitations.map((ws) => ( - - • {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 f3ce41dcf49..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' @@ -40,20 +40,17 @@ 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}. + {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 3e291f48048..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 { @@ -22,21 +22,17 @@ export function WorkspaceAddedEmail({ return ( - Hello, + 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 819388edfbc..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 { @@ -23,9 +23,9 @@ export function WorkspaceInvitationEmail({ return ( - Hello, + 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 e221cce3200..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, @@ -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 @@ -53,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 6e2b0726c64..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' @@ -36,10 +36,10 @@ 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. + 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')}.