improvement(emails): align the email design tokens with the platform design system (#6479)

* improvement(emails): align the email design tokens with the platform design system

* improvement(emails): preview every template in a single gallery page

* improvement(emails): align font stack, logo and social-icon sizing with the platform

* improvement(emails): tokenize the CTA and footnote, and enforce the platform mirror with a test

* fix(emails): restore row spacing in the payment-failed details box

* fix(emails): restore row spacing in the batch-invitation workspace list
This commit is contained in:
Waleed
2026-08-09 18:09:13 -07:00
committed by GitHub
parent 04fd63dad2
commit 0e08a41209
31 changed files with 767 additions and 642 deletions
+116 -28
View File
@@ -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<string, EmailTemplate[]>
/**
* 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<string, EmailTemplate[]> = (() => {
const filed = new Set<string>(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]) => `
<h2 style="margin-top: 24px; margin-bottom: 12px; font-size: 14px; color: #666; text-transform: uppercase; letter-spacing: 0.5px;">${category}</h2>
<ul style="list-style: none; padding: 0; margin: 0;">
${templates.map((t) => `<li style="margin: 8px 0;"><a href="?template=${t}" style="color: #33C482; text-decoration: none; font-size: 16px;">${t}</a></li>`).join('')}
</ul>
`
<section>
<h2>${category}</h2>
<div class="grid">
${templates
.map(
(t) => `
<figure>
<figcaption><span>${t}</span><a href="?template=${t}" target="_blank" rel="noreferrer">open ↗</a></figcaption>
<iframe src="?template=${t}" title="${t}" loading="lazy"></iframe>
</figure>`
)
.join('')}
</div>
</section>`
)
.join('')
@@ -180,15 +254,29 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
`<!DOCTYPE html>
<html>
<head>
<title>Email Previews</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Email Templates</title>
<style>
body { font-family: system-ui, -apple-system, sans-serif; max-width: 600px; margin: 40px auto; padding: 20px; }
h1 { color: #333; margin-bottom: 32px; }
a:hover { text-decoration: underline; }
:root { color-scheme: light; }
body { font-family: ${typography.systemFontFamily}; margin: 0; padding: 40px 24px 80px; background: ${colors.bgCard}; color: ${colors.textPrimary}; }
h1 { font-size: 24px; font-weight: 600; margin: 0 0 4px; }
.count { color: ${colors.textMuted}; font-size: 14px; margin: 0 0 40px; }
h2 { font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: ${colors.textMuted}; margin: 48px 0 16px; padding-bottom: 8px; border-bottom: 1px solid ${colors.border}; }
section { max-width: 1400px; margin: 0 auto; }
section > h2:first-child { margin-top: 0; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(640px, 1fr)); gap: 32px; }
figure { margin: 0 0 32px; }
figcaption { display: flex; justify-content: space-between; align-items: baseline; font-size: 13px; margin-bottom: 8px; }
figcaption span { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: ${colors.textBody}; }
figcaption a { color: ${colors.textMuted}; text-decoration: none; font-size: 12px; }
figcaption a:hover { color: ${colors.textPrimary}; }
iframe { width: 100%; height: 900px; border: 1px solid ${colors.border}; border-radius: 8px; background: ${colors.bgCard}; display: block; }
</style>
</head>
<body>
<h1>Email Templates</h1>
<p class="count">Every email Sim sends — ${Object.keys(emailTemplates).length} previews.</p>
${categoryHtml}
</body>
</html>`,
@@ -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<string, string> = {
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<string, string> = {
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([])
})
})
+163 -111
View File
@@ -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
* `<Font>` 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,
}
/** 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 = {
fontFamily: typography.fontFamily,
/** 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 `<br />` 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',
/** 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,
},
sectionBorder: {
borderBottom: `1px solid ${colors.divider}`,
width: '249px',
},
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
+1 -1
View File
@@ -1 +1 @@
export { baseStyles, colors, plainEmailStyles, spacing, typography } from './base'
export { baseStyles, colors, fontWeight, plainEmailStyles, spacing, typography } from './base'
@@ -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}
>
<Text style={baseStyles.paragraph}>Hello {username},</Text>
<Text style={baseStyles.greeting}>Hello {username},</Text>
<Text style={baseStyles.paragraph}>
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.
</Text>
<Link href={loginLink} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Sign In</Text>
</Link>
<EmailButton href={loginLink}>Sign In</EmailButton>
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
<Text style={baseStyles.footnote}>
If this wasn't you, no action is needed — no account was created or changed.
</Text>
</EmailLayout>
@@ -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 (
<Html>
<Head />
@@ -14,7 +17,7 @@ export function OnboardingFollowupEmail({ userName }: OnboardingFollowupEmailPro
<div style={styles.container}>
<Text style={styles.p}>{userName ? `Hey ${userName},` : 'Hey,'}</Text>
<Text style={styles.p}>
It&apos;s been a few days since you signed up. I hope you&apos;re enjoying Sim!
It&apos;s been a few days since you signed up. I hope you&apos;re enjoying {brand.name}!
</Text>
<Text style={styles.p}>
I&apos;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
<br />
Emir
<br />
Founder, Sim
Founder, {brand.name}
</Text>
</div>
</Body>
@@ -37,7 +37,7 @@ export function OTPVerificationEmail({
return (
<EmailLayout preview={getSubjectByType(type, brand.name, chatTitle)} showUnsubscribe={false}>
<Text style={baseStyles.paragraph}>Your verification code:</Text>
<Text style={baseStyles.greeting}>Your verification code:</Text>
<Section style={baseStyles.codeContainer}>
<Text style={baseStyles.code}>{otp}</Text>
@@ -45,10 +45,9 @@ export function OTPVerificationEmail({
<Text style={baseStyles.paragraph}>This code will expire in 15 minutes.</Text>
{/* Divider */}
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
<Text style={baseStyles.footnote}>
Do not share this code with anyone. If you didn't request this code, you can safely ignore
this email.
</Text>
@@ -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 (
<EmailLayout preview={`Reset your ${brand.name} password`} showUnsubscribe={false}>
<Text style={baseStyles.paragraph}>Hello {username},</Text>
<Text style={baseStyles.greeting}>Hello {username},</Text>
<Text style={baseStyles.paragraph}>
A password reset was requested for your {brand.name} account. Click below to set a new
password.
</Text>
<Link href={resetLink} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Reset Password</Text>
</Link>
<EmailButton href={resetLink}>Reset Password</EmailButton>
{/* Divider */}
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
<Text style={baseStyles.footnote}>
If you didn't request this, you can ignore this email. Link expires in 24 hours.
</Text>
</EmailLayout>
@@ -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 (
<EmailLayout preview={`Welcome to ${brand.name}`} showUnsubscribe={false}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{userName ? `Hey ${userName},` : 'Hey,'}
</Text>
<Text style={baseStyles.greeting}>{userName ? `Hey ${userName},` : 'Hey,'}</Text>
<Text style={baseStyles.paragraph}>
Welcome to {brand.name}! Your account is ready. Start building, testing, and deploying AI
workflows in minutes.
</Text>
<Link href={`${baseUrl}/login`} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Get Started</Text>
</Link>
<EmailButton href={`${baseUrl}/login`}>Get Started</EmailButton>
<Text style={baseStyles.paragraph}>
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) {
<Text style={baseStyles.paragraph}>- Emir, co-founder of {brand.name}</Text>
{/* Divider */}
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
<Text style={baseStyles.footnote}>
You're on the Community plan with 1,000 credits to get started.
</Text>
</EmailLayout>
@@ -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 (
<Html>
<Head />
@@ -14,7 +17,8 @@ export function AbandonedCheckoutEmail({ userName }: AbandonedCheckoutEmailProps
<div style={styles.container}>
<Text style={styles.p}>{userName ? `Hi ${userName},` : 'Hi,'}</Text>
<Text style={styles.p}>
I saw that you tried to upgrade your Sim plan but didn&apos;t end up completing it.
I saw that you tried to upgrade your {brand.name} plan but didn&apos;t end up completing
it.
</Text>
<Text style={styles.p}>
Did you run into an issue, or did you have a question? Here to help.
@@ -22,7 +26,7 @@ export function AbandonedCheckoutEmail({ userName }: AbandonedCheckoutEmailProps
<Text style={styles.p}>
— Emir
<br />
Founder, Sim
Founder, {brand.name}
</Text>
</div>
</Body>
@@ -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 (
<EmailLayout preview={previewText} showUnsubscribe={false}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{userName ? `Hi ${userName},` : 'Hi,'}
</Text>
<Text style={baseStyles.greeting}>{userName ? `Hi ${userName},` : 'Hi,'}</Text>
<Text style={baseStyles.paragraph}>
Your credit purchase of <strong>{dollarsToCredits(amount).toLocaleString()} credits</strong>{' '}
has been confirmed.
Your credit purchase of{' '}
<EmailStrong>{dollarsToCredits(amount).toLocaleString()} credits</EmailStrong> has been
confirmed.
</Text>
<Section style={baseStyles.infoBox}>
<Text
style={{
margin: 0,
fontSize: '14px',
color: colors.textMuted,
fontFamily: baseStyles.fontFamily,
}}
>
Amount Added
</Text>
<Text
style={{
margin: '4px 0 16px',
fontSize: '24px',
fontWeight: 'bold',
color: colors.textPrimary,
fontFamily: baseStyles.fontFamily,
}}
>
<Text style={baseStyles.infoBoxLabel}>Amount Added</Text>
<Text style={{ ...baseStyles.infoBoxValue, marginBottom: '16px' }}>
{dollarsToCredits(amount).toLocaleString()} credits
</Text>
<Text
style={{
margin: 0,
fontSize: '14px',
color: colors.textMuted,
fontFamily: baseStyles.fontFamily,
}}
>
New Balance
</Text>
<Text
style={{
margin: '4px 0 0',
fontSize: '24px',
fontWeight: 'bold',
color: colors.textPrimary,
fontFamily: baseStyles.fontFamily,
}}
>
<Text style={baseStyles.infoBoxLabel}>New Balance</Text>
<Text style={baseStyles.infoBoxValue}>
{dollarsToCredits(newBalance).toLocaleString()} credits
</Text>
</Section>
@@ -82,14 +47,11 @@ export function CreditPurchaseEmail({
Credits are applied automatically to your workflow executions.
</Text>
<Link href={`${baseUrl}/workspace`} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>View Dashboard</Text>
</Link>
<EmailButton href={`${baseUrl}/workspace`}>View Dashboard</EmailButton>
{/* Divider */}
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
<Text style={baseStyles.footnote}>
Purchased on {purchaseDate.toLocaleDateString()}. View balance in Settings → Subscription.
</Text>
</EmailLayout>
@@ -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}
>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{userName ? `Hi ${userName},` : 'Hi,'}
</Text>
<Text style={baseStyles.greeting}>{userName ? `Hi ${userName},` : 'Hi,'}</Text>
<Text style={baseStyles.paragraph}>
You&apos;ve used all <strong>{dollarsToCredits(limit).toLocaleString()}</strong> of your
free credits on {brand.name}. Your workflows are paused until you upgrade.
You&apos;ve used all <EmailStrong>{dollarsToCredits(limit).toLocaleString()}</EmailStrong>{' '}
of your free credits on {brand.name}. Your workflows are paused until you upgrade.
</Text>
<Section
style={{
backgroundColor: colors.surfaceSubtle,
border: `1px solid ${colors.brandTertiary}20`,
borderRadius: '8px',
padding: '16px 20px',
margin: '16px 0',
}}
>
<Text
style={{
fontSize: '14px',
fontWeight: 600,
color: colors.brandTertiary,
fontFamily: typography.fontFamily,
margin: '0 0 12px 0',
textTransform: 'uppercase' as const,
letterSpacing: '0.5px',
}}
>
Pro includes
</Text>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<tbody>
{proFeatures.map((feature, i) => (
<tr key={i}>
<td
style={{
padding: '6px 0',
fontSize: '15px',
fontWeight: 600,
color: colors.textPrimary,
fontFamily: typography.fontFamily,
width: '45%',
}}
>
{feature.label}
</td>
<td
style={{
padding: '6px 0',
fontSize: '14px',
color: colors.textMuted,
fontFamily: typography.fontFamily,
}}
>
{feature.desc}
</td>
</tr>
))}
</tbody>
</table>
</Section>
<ProFeaturesBox />
<Link href={upgradeLink} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Upgrade to Pro</Text>
</Link>
<EmailButton href={upgradeLink}>Upgrade to Pro</EmailButton>
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
<Text style={baseStyles.footnote}>
One-time notification when free credits are exhausted.
</Text>
</EmailLayout>
@@ -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}
>
<Text style={baseStyles.paragraph}>Hello {userName},</Text>
<Text style={baseStyles.greeting}>Hello {userName},</Text>
<Text style={baseStyles.paragraph}>
Your <strong>Enterprise Plan</strong> is now active. You have full access to advanced
features and increased capacity for your workflows.
Your <EmailStrong>Enterprise Plan</EmailStrong> is now active. You have full access to
advanced features and increased capacity for your workflows.
</Text>
<Link href={effectiveLoginLink} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Open {brand.name}</Text>
</Link>
<Section style={baseStyles.infoBox}>
<Text style={baseStyles.infoBoxTitle}>Next steps</Text>
<Text style={baseStyles.infoBoxList}>
• Invite teammates to your organization
<br />• Start building your workflows
</Text>
</Section>
<Text style={baseStyles.paragraph}>
<strong>Next steps:</strong>
<br />• Invite teammates to your organization
<br />• Start building your workflows
</Text>
<EmailButton href={effectiveLoginLink}>Open {brand.name}</EmailButton>
{/* Divider */}
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
<Text style={baseStyles.footnote}>
Questions? Reply to this email or contact us at{' '}
<Link href={`mailto:${brand.supportEmail}`} style={baseStyles.link}>
{brand.supportEmail}
@@ -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 (
<EmailLayout preview={previewText} showUnsubscribe={true}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{userName ? `Hi ${userName},` : 'Hi,'}
</Text>
<Text style={baseStyles.greeting}>{userName ? `Hi ${userName},` : 'Hi,'}</Text>
<Text style={baseStyles.paragraph}>
You've used <strong>{dollarsToCredits(currentUsage).toLocaleString()}</strong> of your{' '}
<strong>{dollarsToCredits(limit).toLocaleString()}</strong> free credits ({percentUsed}%).
Upgrade to Pro to keep building without interruption.
You've used <EmailStrong>{dollarsToCredits(currentUsage).toLocaleString()}</EmailStrong> of
your <EmailStrong>{dollarsToCredits(limit).toLocaleString()}</EmailStrong> free credits (
{percentUsed}%). Upgrade to Pro to keep building without interruption.
</Text>
{/* Pro Features */}
<Section
style={{
backgroundColor: colors.surfaceSubtle,
border: `1px solid ${colors.brandTertiary}20`,
borderRadius: '8px',
padding: '16px 20px',
margin: '16px 0',
}}
>
<Text
style={{
fontSize: '14px',
fontWeight: 600,
color: colors.brandTertiary,
fontFamily: typography.fontFamily,
margin: '0 0 12px 0',
textTransform: 'uppercase' as const,
letterSpacing: '0.5px',
}}
>
Pro includes
</Text>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<tbody>
{proFeatures.map((feature, i) => (
<tr key={i}>
<td
style={{
padding: '6px 0',
fontSize: '15px',
fontWeight: 600,
color: colors.textPrimary,
fontFamily: typography.fontFamily,
width: '45%',
}}
>
{feature.label}
</td>
<td
style={{
padding: '6px 0',
fontSize: '14px',
color: colors.textMuted,
fontFamily: typography.fontFamily,
}}
>
{feature.desc}
</td>
</tr>
))}
</tbody>
</table>
</Section>
<ProFeaturesBox />
<Link href={upgradeLink} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Upgrade to Pro</Text>
</Link>
<EmailButton href={upgradeLink}>Upgrade to Pro</EmailButton>
{/* Divider */}
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
One-time notification at 80% usage.
</Text>
<Text style={baseStyles.footnote}>One-time notification at 80% usage.</Text>
</EmailLayout>
)
}
@@ -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 (
<EmailLayout preview={previewText} showUnsubscribe={true}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{userName ? `Hi ${userName},` : 'Hi,'}
</Text>
<Text style={baseStyles.greeting}>{userName ? `Hi ${userName},` : 'Hi,'}</Text>
<Text style={baseStyles.paragraph}>
{lead} Upgrade your plan for more {copy.noun}.
@@ -54,17 +52,11 @@ export function LimitThresholdEmail({
</Text>
</Section>
{/* Divider */}
<EmailButton href={upgradeLink}>Upgrade</EmailButton>
<div style={baseStyles.divider} />
<Link href={upgradeLink} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Upgrade</Text>
</Link>
{/* Divider */}
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
<Text style={baseStyles.footnote}>
{kind === 'reached'
? 'One-time notification at 100% usage.'
: 'One-time notification at 80% usage.'}
@@ -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 (
<EmailLayout preview={previewText} showUnsubscribe={false}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{userName ? `Hi ${userName},` : 'Hi,'}
</Text>
<Text style={baseStyles.greeting}>{userName ? `Hi ${userName},` : 'Hi,'}</Text>
<Text
style={{
...baseStyles.paragraph,
fontSize: '16px',
fontWeight: 600,
fontWeight: fontWeight.semibold,
color: colors.textPrimary,
}}
>
@@ -46,57 +43,39 @@ export function PaymentFailedEmail({
unexpected charges. To restore access immediately, please update your payment method.
</Text>
<Section
style={{
backgroundColor: colors.errorBg,
border: `1px solid ${colors.errorBorder}`,
borderRadius: '6px',
padding: '16px 18px',
margin: '16px 0',
}}
>
<Text
style={{
...baseStyles.paragraph,
marginBottom: 8,
marginTop: 0,
fontWeight: 'bold',
}}
>
Payment Details
</Text>
<Text style={{ ...baseStyles.paragraph, margin: '4px 0' }}>
<Section style={baseStyles.errorBox}>
<Text style={baseStyles.infoBoxTitle}>Payment Details</Text>
<Text style={baseStyles.infoBoxList}>
Amount due: ${amountDue.toFixed(2)}
{lastFourDigits && (
<>
<br />
Payment method: •••• {lastFourDigits}
</>
)}
{failureReason && (
<>
<br />
Reason: {failureReason}
</>
)}
</Text>
{lastFourDigits && (
<Text style={{ ...baseStyles.paragraph, margin: '4px 0' }}>
Payment method: •••• {lastFourDigits}
</Text>
)}
{failureReason && (
<Text style={{ ...baseStyles.paragraph, margin: '4px 0' }}>Reason: {failureReason}</Text>
)}
</Section>
<Link href={billingPortalUrl} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Update Payment Method</Text>
</Link>
<Section style={baseStyles.infoBox}>
<Text style={baseStyles.infoBoxTitle}>What happens next</Text>
<Text style={baseStyles.infoBoxList}>
• Your workflows and automations are currently paused
<br />• Update your payment method to restore service immediately
<br />• Stripe will automatically retry the charge once payment is updated
</Text>
</Section>
<EmailButton href={billingPortalUrl}>Update Payment Method</EmailButton>
{/* Divider */}
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.paragraph, fontWeight: 'bold' }}>What happens next?</Text>
<Text style={baseStyles.paragraph}>
• Your workflows and automations are currently paused
<br />• Update your payment method to restore service immediately
<br />• Stripe will automatically retry the charge once payment is updated
</Text>
{/* Divider */}
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
<Text style={baseStyles.footnote}>
Common issues: expired card, insufficient funds, or incorrect billing info. Need help?{' '}
<Link href={`mailto:${brand.supportEmail}`} style={baseStyles.link}>
{brand.supportEmail}
@@ -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 (
<EmailLayout preview={previewText} showUnsubscribe={true}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{userName ? `Hi ${userName},` : 'Hi,'}
</Text>
<Text style={baseStyles.greeting}>{userName ? `Hi ${userName},` : 'Hi,'}</Text>
<Text style={baseStyles.paragraph}>
Welcome to <strong>{planName}</strong>! You're all set to build, test, and scale your
workflows.
Welcome to <EmailStrong>{planName}</EmailStrong>! You're all set to build, test, and scale
your workflows.
</Text>
<Link href={cta} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Open {brand.name}</Text>
</Link>
<EmailButton href={cta}>Open {brand.name}</EmailButton>
<Text style={baseStyles.paragraph}>
Want help getting started?{' '}
@@ -39,12 +35,9 @@ export function PlanWelcomeEmail({ planName, userName, loginLink }: PlanWelcomeE
with our team.
</Text>
{/* Divider */}
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
Manage your subscription in Settings → Subscription.
</Text>
<Text style={baseStyles.footnote}>Manage your subscription in Settings → Subscription.</Text>
</EmailLayout>
)
}
@@ -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 (
<Section style={baseStyles.infoBox}>
<Text style={baseStyles.infoBoxTitle}>Pro includes</Text>
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<tbody>
{proFeatures.map((feature) => (
<tr key={feature.label}>
<td style={LABEL_CELL}>{feature.label}</td>
<td style={CELL}>{feature.desc}</td>
</tr>
))}
</tbody>
</table>
</Section>
)
}
export default ProFeaturesBox
@@ -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 (
<EmailLayout preview={previewText} showUnsubscribe={true}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{userName ? `Hi ${userName},` : 'Hi,'}
</Text>
<Text style={baseStyles.greeting}>{userName ? `Hi ${userName},` : 'Hi,'}</Text>
<Text style={baseStyles.paragraph}>
{isOrganization
@@ -53,25 +51,19 @@ export function UsageLimitReachedEmail({
</Text>
</Section>
{/* Divider */}
<div style={baseStyles.divider} />
<Text style={baseStyles.paragraph}>
{isOrganization
? 'Raise the organization usage limit in billing settings to resume.'
: 'Raise your usage limit in billing settings, or upgrade your plan, to resume.'}
</Text>
<Link href={ctaLink} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>
{isOrganization ? 'Raise Organization Limit' : 'Raise Usage Limit'}
</Text>
</Link>
<EmailButton href={ctaLink}>
{isOrganization ? 'Raise Organization Limit' : 'Raise Usage Limit'}
</EmailButton>
{/* Divider */}
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
<Text style={baseStyles.footnote}>
Sent to the people who manage billing for this account.
</Text>
</EmailLayout>
@@ -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 (
<EmailLayout preview={previewText} showUnsubscribe={true}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{userName ? `Hi ${userName},` : 'Hi,'}
</Text>
<Text style={baseStyles.greeting}>{userName ? `Hi ${userName},` : 'Hi,'}</Text>
<Text style={baseStyles.paragraph}>
You're approaching your monthly budget on the {planName} plan.
@@ -43,23 +41,15 @@ export function UsageThresholdEmail({
</Text>
</Section>
{/* Divider */}
<div style={baseStyles.divider} />
<Text style={baseStyles.paragraph}>
To avoid interruptions, consider increasing your monthly limit.
</Text>
<Link href={ctaLink} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Review Limits</Text>
</Link>
<EmailButton href={ctaLink}>Review Limits</EmailButton>
{/* Divider */}
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
One-time notification at 80% usage.
</Text>
<Text style={baseStyles.footnote}>One-time notification at 80% usage.</Text>
</EmailLayout>
)
}
@@ -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 (
<Link href={href} style={RESET_LINK_STYLE}>
<Text style={baseStyles.button}>{children}</Text>
</Link>
)
}
export default EmailButton
@@ -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 (
<Section
style={{
@@ -80,50 +89,19 @@ export function EmailFooter({
<table cellPadding={0} cellSpacing={0} style={{ border: 0 }}>
<tbody>
<tr>
<td align='left' style={{ padding: '0 8px 0 0' }}>
<Link href={`${baseUrl}/x`} rel='noopener noreferrer'>
<Img
src={`${baseUrl}/static/x-icon.png`}
width='20'
height='20'
alt='X'
style={socialIconStyle}
/>
</Link>
</td>
<td align='left' style={{ padding: '0 8px' }}>
<Link href={`${baseUrl}/linkedin`} rel='noopener noreferrer'>
<Img
src={`${baseUrl}/static/linkedin-icon.png`}
width='20'
height='20'
alt='LinkedIn'
style={socialIconStyle}
/>
</Link>
</td>
<td align='left' style={{ padding: '0 8px' }}>
<Link href={`${baseUrl}/github`} rel='noopener noreferrer'>
<Img
src={`${baseUrl}/static/github-icon.png`}
width='20'
height='20'
alt='GitHub'
style={socialIconStyle}
/>
</Link>
</td>
<td align='left' style={{ padding: '0 8px' }}>
<Link href={`${baseUrl}/slack`} rel='noopener noreferrer'>
<Img
src={`${baseUrl}/static/slack-icon.png`}
width='20'
height='20'
alt='Slack'
style={socialIconStyle}
/>
</Link>
</td>
{SOCIAL_LINKS.map(({ path, label, icon }) => (
<td key={path} align='left' style={SOCIAL_CELL_STYLE}>
<Link href={`${baseUrl}/${path}`} rel='noopener noreferrer'>
<Img
src={`${baseUrl}/static/${icon}`}
width={SOCIAL_ICON_SIZE}
height={SOCIAL_ICON_SIZE}
alt={label}
style={socialIconStyle}
/>
</Link>
</td>
))}
</tr>
</tbody>
</table>
@@ -165,14 +143,18 @@ export function EmailFooter({
</>
)}
{/* Contact row */}
<tr>
<td style={baseStyles.gutter} width={spacing.gutter}>
&nbsp;
</td>
<td style={baseStyles.footerText}>
Questions?{' '}
<a href={`mailto:${brand.supportEmail}`} style={footerLinkStyle}>
{/*
A raw anchor, not `<Link>`: react-email's Link hardcodes
target="_blank", which on a mailto: opens a blank tab beside
the compose window in most webmail clients.
*/}
<a href={`mailto:${brand.supportEmail}`} style={baseStyles.footerLink}>
{brand.supportEmail}
</a>
</td>
@@ -187,7 +169,6 @@ export function EmailFooter({
</td>
</tr>
{/* Message ID row (optional) */}
{messageId && (
<>
<tr>
@@ -209,30 +190,37 @@ export function EmailFooter({
</>
)}
{/* Links row */}
<tr>
<td style={baseStyles.gutter} width={spacing.gutter}>
&nbsp;
</td>
<td style={baseStyles.footerText}>
<a href={`${baseUrl}/privacy`} style={footerLinkStyle} rel='noopener noreferrer'>
<Link
href={`${baseUrl}/privacy`}
style={baseStyles.footerLink}
rel='noopener noreferrer'
>
Privacy Policy
</a>{' '}
</Link>{' '}
•{' '}
<a href={`${baseUrl}/terms`} style={footerLinkStyle} rel='noopener noreferrer'>
<Link
href={`${baseUrl}/terms`}
style={baseStyles.footerLink}
rel='noopener noreferrer'
>
Terms of Service
</a>
</Link>
{showUnsubscribe && (
<>
{' '}
•{' '}
<a
<Link
href={`${baseUrl}/unsubscribe?token={{UNSUBSCRIBE_TOKEN}}&email={{UNSUBSCRIBE_EMAIL}}`}
style={footerLinkStyle}
style={baseStyles.footerLink}
rel='noopener noreferrer'
>
Unsubscribe
</a>
</Link>
</>
)}
</td>
@@ -241,7 +229,6 @@ export function EmailFooter({
</td>
</tr>
{/* Copyright row */}
<tr>
<td style={baseStyles.spacer} height={16}>
&nbsp;
@@ -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 (
<Html>
<Head>
{/*
`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.
*/}
<Font
fontFamily='Season Sans'
fallbackFontFamily={['Helvetica', 'sans-serif']}
fallbackFontFamily={['Helvetica', 'Arial', 'sans-serif']}
webFont={{
url: `${baseUrl}/brand/fonts/SeasonSansUprightsVF.woff2`,
format: 'woff2',
@@ -48,23 +65,19 @@ export function EmailLayout({
</Head>
<Preview>{preview}</Preview>
<Body style={baseStyles.main}>
{/* Main card container */}
<Container style={baseStyles.container}>
{/* Header with logo */}
<Section style={baseStyles.header}>
<Img
src={brand.logoUrl || `${baseUrl}/brand/color/email/wordmark.png`}
alt={brand.name}
{...(hasCustomLogo ? { height: '34' } : { height: '41', width: '68' })}
{...(hasCustomLogo ? CUSTOM_LOGO_SIZE : WORDMARK_SIZE)}
style={hasCustomLogo ? { display: 'block', width: 'auto' } : { display: 'block' }}
/>
</Section>
{/* Content */}
<Section style={baseStyles.content}>{children}</Section>
</Container>
{/* Footer in gray section */}
{!hideFooter && <EmailFooter baseUrl={baseUrl} showUnsubscribe={showUnsubscribe} />}
</Body>
</Html>
@@ -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 <strong style={STRONG_STYLE}>{children}</strong>
}
export default EmailStrong
@@ -1,2 +1,4 @@
export { EmailButton } from './email-button'
export { EmailFooter } from './email-footer'
export { EmailLayout } from './email-layout'
export { EmailStrong } from './email-strong'
@@ -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}
>
<Text style={baseStyles.paragraph}>Hello,</Text>
<Text style={baseStyles.greeting}>Hello,</Text>
<Text style={baseStyles.paragraph}>
<strong>{inviterName}</strong> has invited you to join <strong>{organizationName}</strong>{' '}
on {brand.name}.
<EmailStrong>{inviterName}</EmailStrong> has invited you to join{' '}
<EmailStrong>{organizationName}</EmailStrong> on {brand.name}.
</Text>
{/* Team Role Information */}
<Text style={baseStyles.paragraph}>
<strong>Team Role:</strong> {getRoleLabel(organizationRole)}
</Text>
<Text style={baseStyles.paragraph}>
{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."}
</Text>
<Section style={baseStyles.infoBox}>
<Text style={baseStyles.infoBoxTitle}>Team role</Text>
<Text style={baseStyles.infoBoxList}>
{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.'}
</Text>
</Section>
{/* Workspace Invitations */}
{hasWorkspaces && (
<>
<Text style={baseStyles.paragraph}>
<strong>
Workspace Access ({workspaceInvitations.length} workspace
{workspaceInvitations.length !== 1 ? 's' : ''}):
</strong>
<Section style={baseStyles.infoBox}>
<Text style={baseStyles.infoBoxTitle}>
Workspace access ({workspaceInvitations.length} workspace
{workspaceInvitations.length !== 1 ? 's' : ''})
</Text>
{workspaceInvitations.map((ws) => (
<Text key={ws.workspaceId} style={{ ...baseStyles.paragraph, marginLeft: '20px' }}>
• <strong>{ws.workspaceName}</strong> - {getPermissionLabel(ws.permission)}
</Text>
))}
</>
<Text style={baseStyles.infoBoxList}>
{workspaceInvitations.map((ws, index) => (
<Fragment key={ws.workspaceId}>
{index > 0 && <br />}
{ws.workspaceName} — {getPermissionLabel(ws.permission)}
</Fragment>
))}
</Text>
</Section>
)}
<Link href={acceptUrl} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Accept Invitation</Text>
</Link>
<EmailButton href={acceptUrl}>Accept Invitation</EmailButton>
{/* Divider */}
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
<Text style={baseStyles.footnote}>
Invitation expires in 7 days. If unexpected, you can ignore this email.
</Text>
</EmailLayout>
@@ -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}
>
<Text style={baseStyles.paragraph}>Hello,</Text>
<Text style={baseStyles.greeting}>Hello,</Text>
<Text style={baseStyles.paragraph}>
<strong>{inviterName}</strong> invited you to join <strong>{organizationName}</strong> on{' '}
{brand.name}.
<EmailStrong>{inviterName}</EmailStrong> invited you to join{' '}
<EmailStrong>{organizationName}</EmailStrong> on {brand.name}.
</Text>
<Link href={enhancedLink} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Accept Invitation</Text>
</Link>
<EmailButton href={enhancedLink}>Accept Invitation</EmailButton>
{/* Divider */}
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
<Text style={baseStyles.footnote}>
Invitation expires in 48 hours. If unexpected, you can ignore this email.
</Text>
</EmailLayout>
@@ -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 (
<EmailLayout preview={preview} showUnsubscribe={false}>
<Text style={baseStyles.paragraph}>Hello,</Text>
<Text style={baseStyles.greeting}>Hello,</Text>
<Text style={baseStyles.paragraph}>
<strong>{inviterName}</strong> added you to the <strong>{workspaceName}</strong> workspace
on {brand.name}.
<EmailStrong>{inviterName}</EmailStrong> added you to the{' '}
<EmailStrong>{workspaceName}</EmailStrong> workspace on {brand.name}.
</Text>
<Link href={workspaceLink} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Open workspace</Text>
</Link>
<EmailButton href={workspaceLink}>Open workspace</EmailButton>
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
If this was unexpected, contact a workspace admin.
</Text>
<Text style={baseStyles.footnote}>If this was unexpected, contact a workspace admin.</Text>
</EmailLayout>
)
}
@@ -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 (
<EmailLayout preview={preview} showUnsubscribe={false}>
<Text style={baseStyles.paragraph}>Hello,</Text>
<Text style={baseStyles.greeting}>Hello,</Text>
<Text style={baseStyles.paragraph}>
<strong>{inviterName}</strong> invited you to join the{' '}
<EmailStrong>{inviterName}</EmailStrong> invited you to join the{' '}
{workspaceNames.map((name, index) => (
<span key={`${name}-${index}`}>
{index > 0 &&
@@ -34,19 +34,17 @@ export function WorkspaceInvitationEmail({
? ', and '
: ' and '
: ', ')}
<strong>{name}</strong>
<EmailStrong>{name}</EmailStrong>
</span>
))}{' '}
{isMultiple ? 'workspaces' : 'workspace'} on {brand.name}.
</Text>
<Link href={invitationLink} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Accept Invitation</Text>
</Link>
<EmailButton href={invitationLink}>Accept Invitation</EmailButton>
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
<Text style={baseStyles.footnote}>
Invitation expires in 7 days. If unexpected, you can ignore this email.
</Text>
</EmailLayout>
@@ -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 (
<EmailLayout preview={previewText} showUnsubscribe={true}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{recipientName ? `Hi ${recipientName},` : 'Hi,'}
</Text>
<Text style={baseStyles.greeting}>{recipientName ? `Hi ${recipientName},` : 'Hi,'}</Text>
<Text style={baseStyles.paragraph}>
{brand.name} turned off the schedule for {resourceLabel}. It will not run again until you
@@ -53,16 +51,11 @@ export function ScheduleDisabledEmail({
<Text style={baseStyles.infoBoxList}>{reasonCopy}</Text>
</Section>
{manageLink ? (
<Link href={manageLink} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Open workflow</Text>
</Link>
) : null}
{manageLink ? <EmailButton href={manageLink}>Open workflow</EmailButton> : null}
{/* Divider */}
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
<Text style={baseStyles.footnote}>
Sent to workspace admins and the person who created this schedule.
</Text>
</EmailLayout>
@@ -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}
>
<Text style={baseStyles.paragraph}>Hello,</Text>
<Text style={baseStyles.greeting}>Hello,</Text>
<Text style={baseStyles.paragraph}>
We've received your <strong>{typeLabel.toLowerCase()}</strong> and will get back to you
shortly.
We've received your <EmailStrong>{typeLabel.toLowerCase()}</EmailStrong> and will get back
to you shortly.
</Text>
{attachmentCount > 0 && (
@@ -48,10 +48,9 @@ export function HelpConfirmationEmail({
</Text>
)}
{/* Divider */}
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
<Text style={baseStyles.footnote}>
Submitted on {format(submittedDate, 'MMMM do, yyyy')}.
</Text>
</EmailLayout>