chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:20:55 +08:00
commit d25d482dc2
13754 changed files with 4996608 additions and 0 deletions
+299
View File
@@ -0,0 +1,299 @@
/**
* Base styles for all email templates.
* Colors are derived from globals.css light mode tokens.
*/
import { getBrandConfig } from '@/ee/whitelabeling'
/** Color tokens from globals.css (light mode), brand-aware for whitelabeled instances */
function buildColors() {
const brand = getBrandConfig()
const isWhitelabeled = brand.isWhitelabeled
const accentColor =
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',
/** Card/container background — platform `--surface-2` */
bgCard: '#ffffff',
/** Primary text — 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',
/** 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 */
surfaceSubtle: '#f7f7f7',
/** Error surface fill — platform `--terminal-status-error-bg` */
errorBg: '#fef2f2',
/** Error surface border — platform `--error-muted` */
errorBorder: '#fecaca',
/** Footer background — matches the canvas */
footerBg: '#f8f8f8',
}
}
export const colors = buildColors()
/** Typography settings */
export const typography = {
fontFamily:
"'Season Sans', -apple-system, 'SF Pro Display', 'SF Pro Text', 'Helvetica', sans-serif",
fontSize: {
body: '16px',
small: '14px',
caption: '12px',
},
lineHeight: {
body: '24px',
caption: '20px',
},
}
/** Spacing values */
export const spacing = {
containerWidth: 600,
gutter: 40,
sectionGap: 20,
paragraphGap: 12,
/** Logo width in pixels */
logoWidth: 90,
}
export const baseStyles = {
fontFamily: typography.fontFamily,
/** Main body wrapper with outer background */
main: {
backgroundColor: colors.bgOuter,
fontFamily: typography.fontFamily,
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',
overflow: 'hidden',
},
/** Header section with logo */
header: {
padding: `32px ${spacing.gutter}px 16px ${spacing.gutter}px`,
textAlign: 'left' as const,
},
/** Main content area with horizontal padding */
content: {
padding: `0 ${spacing.gutter}px 32px ${spacing.gutter}px`,
},
/** Standard paragraph text */
paragraph: {
fontSize: typography.fontSize.body,
lineHeight: typography.lineHeight.body,
color: colors.textSecondary,
fontWeight: 400,
fontFamily: typography.fontFamily,
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',
},
/** Primary CTA button - matches the platform's primary Chip (inverse fill, rounded-lg, h-30, text-sm) */
button: {
display: 'inline-block',
backgroundColor: colors.brandTertiary,
color: '#ffffff',
fontWeight: 400,
fontSize: '14px',
lineHeight: '30px',
padding: '0 12px',
borderRadius: '8px',
textDecoration: 'none',
textAlign: 'center' as const,
margin: '4px 0',
fontFamily: typography.fontFamily,
},
/** Link text style - neutral color, so it carries an underline to read as a link */
link: {
color: colors.brandTertiary,
fontWeight: 400,
textDecoration: 'underline',
},
/** Horizontal divider */
divider: {
borderTop: `1px solid ${colors.divider}`,
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 text style */
footerText: {
fontSize: typography.fontSize.caption,
lineHeight: typography.lineHeight.caption,
color: colors.textMuted,
fontFamily: typography.fontFamily,
margin: '0 0 10px 0',
},
/** Code/OTP container */
codeContainer: {
margin: '12px 0',
padding: '12px 16px',
backgroundColor: colors.surfaceSubtle,
borderRadius: '8px',
border: `1px solid ${colors.divider}`,
textAlign: 'center' as const,
},
/** Code/OTP text */
code: {
fontSize: '24px',
fontWeight: 'bold' as const,
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: {
backgroundColor: colors.surfaceSubtle,
padding: '16px 18px',
borderRadius: '8px',
margin: '16px 0',
},
/** Info box title */
infoBoxTitle: {
fontSize: typography.fontSize.body,
lineHeight: typography.lineHeight.body,
fontWeight: 600,
color: colors.textPrimary,
fontFamily: typography.fontFamily,
margin: '0 0 8px 0',
},
/** Info box list content */
infoBoxList: {
fontSize: typography.fontSize.body,
lineHeight: '1.6',
color: colors.textSecondary,
fontFamily: typography.fontFamily,
margin: 0,
},
/** Section borders - decorative accent line */
sectionsBorders: {
width: '100%',
display: 'flex',
},
sectionBorder: {
borderBottom: `1px solid ${colors.divider}`,
width: '249px',
},
sectionCenter: {
borderBottom: `1px solid ${colors.brandTertiary}`,
width: '102px',
},
/** Spacer row for vertical spacing in tables */
spacer: {
border: 0,
margin: 0,
padding: 0,
fontSize: '1px',
lineHeight: '1px',
},
/** Gutter cell for horizontal padding in tables */
gutter: {
border: 0,
margin: 0,
padding: 0,
fontSize: '1px',
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) */
export const plainEmailStyles = {
body: {
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif',
backgroundColor: '#ffffff',
margin: '0',
padding: '0',
},
container: {
maxWidth: '560px',
margin: '40px auto',
padding: '0 24px',
},
p: {
fontSize: '15px',
lineHeight: '1.6',
color: '#1a1a1a',
margin: '0 0 16px',
},
} as const
@@ -0,0 +1 @@
export { baseStyles, colors, plainEmailStyles, spacing, typography } from './base'
@@ -0,0 +1,46 @@
import { Link, Text } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getBrandConfig } from '@/ee/whitelabeling'
interface ExistingAccountEmailProps {
username?: string
}
/**
* Sent out-of-band when someone attempts to sign up with an email that already
* has an account. The sign-up endpoint itself returns a generic success
* response to avoid account enumeration, so this email is how the real account
* owner learns of the attempt.
*/
export function ExistingAccountEmail({ username = '' }: ExistingAccountEmailProps) {
const brand = getBrandConfig()
const loginLink = `${getBaseUrl()}/login`
return (
<EmailLayout
preview={`Someone tried to sign up with your ${brand.name} email`}
showUnsubscribe={false}
>
<Text style={baseStyles.paragraph}>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>
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
If this wasn't you, no action is needed no account was created or changed.
</Text>
</EmailLayout>
)
}
export default ExistingAccountEmail
+5
View File
@@ -0,0 +1,5 @@
export { ExistingAccountEmail } from './existing-account-email'
export { OnboardingFollowupEmail } from './onboarding-followup-email'
export { OTPVerificationEmail } from './otp-verification-email'
export { ResetPasswordEmail } from './reset-password-email'
export { WelcomeEmail } from './welcome-email'
@@ -0,0 +1,38 @@
import { Body, Head, Html, Preview, Text } from '@react-email/components'
import { plainEmailStyles as styles } from '@/components/emails/_styles'
interface OnboardingFollowupEmailProps {
userName?: string
}
export function OnboardingFollowupEmail({ userName }: OnboardingFollowupEmailProps) {
return (
<Html>
<Head />
<Preview>Quick question</Preview>
<Body style={styles.body}>
<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!
</Text>
<Text style={styles.p}>
I&apos;d love to know what did you expect when you signed up vs. what did you get?
</Text>
<Text style={styles.p}>
A reply with your thoughts would really help us improve the product for everyone.
</Text>
<Text style={styles.p}>
Thanks,
<br />
Emir
<br />
Founder, Sim
</Text>
</div>
</Body>
</Html>
)
}
export default OnboardingFollowupEmail
@@ -0,0 +1,59 @@
import { Section, Text } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
import { getBrandConfig } from '@/ee/whitelabeling'
interface OTPVerificationEmailProps {
otp: string
email?: string
type?: 'sign-in' | 'email-verification' | 'change-email' | 'forget-password' | 'chat-access'
chatTitle?: string
}
const getSubjectByType = (type: string, brandName: string, chatTitle?: string) => {
switch (type) {
case 'sign-in':
return `Sign in to ${brandName}`
case 'email-verification':
return `Verify your email for ${brandName}`
case 'change-email':
return `Verify your new email for ${brandName}`
case 'forget-password':
return `Reset your ${brandName} password`
case 'chat-access':
return `Verification code for ${chatTitle || 'Chat'}`
default:
return `Verification code for ${brandName}`
}
}
export function OTPVerificationEmail({
otp,
email = '',
type = 'email-verification',
chatTitle,
}: OTPVerificationEmailProps) {
const brand = getBrandConfig()
return (
<EmailLayout preview={getSubjectByType(type, brand.name, chatTitle)} showUnsubscribe={false}>
<Text style={baseStyles.paragraph}>Your verification code:</Text>
<Section style={baseStyles.codeContainer}>
<Text style={baseStyles.code}>{otp}</Text>
</Section>
<Text style={baseStyles.paragraph}>This code will expire in 15 minutes.</Text>
{/* Divider */}
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
Do not share this code with anyone. If you didn't request this code, you can safely ignore
this email.
</Text>
</EmailLayout>
)
}
export default OTPVerificationEmail
@@ -0,0 +1,36 @@
import { Link, Text } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
import { getBrandConfig } from '@/ee/whitelabeling'
interface ResetPasswordEmailProps {
username?: string
resetLink?: string
}
export function ResetPasswordEmail({ username = '', resetLink = '' }: ResetPasswordEmailProps) {
const brand = getBrandConfig()
return (
<EmailLayout preview={`Reset your ${brand.name} password`} showUnsubscribe={false}>
<Text style={baseStyles.paragraph}>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>
{/* Divider */}
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
If you didn't request this, you can ignore this email. Link expires in 24 hours.
</Text>
</EmailLayout>
)
}
export default ResetPasswordEmail
@@ -0,0 +1,53 @@
import { Link, Text } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getBrandConfig } from '@/ee/whitelabeling'
interface WelcomeEmailProps {
userName?: string
}
export function WelcomeEmail({ userName }: WelcomeEmailProps) {
const brand = getBrandConfig()
const baseUrl = getBaseUrl()
return (
<EmailLayout preview={`Welcome to ${brand.name}`} showUnsubscribe={false}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{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>
<Text style={baseStyles.paragraph}>
If you have any questions or feedback, just reply to this email. I read every message!
</Text>
<Text style={baseStyles.paragraph}>
Want to chat?{' '}
<Link href={`${baseUrl}/team`} style={baseStyles.link}>
Schedule a call
</Link>{' '}
with our team.
</Text>
<Text style={baseStyles.paragraph}>- Emir, co-founder of {brand.name}</Text>
{/* Divider */}
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
You're on the Community plan with 1,000 credits to get started.
</Text>
</EmailLayout>
)
}
export default WelcomeEmail
@@ -0,0 +1,33 @@
import { Body, Head, Html, Preview, Text } from '@react-email/components'
import { plainEmailStyles as styles } from '@/components/emails/_styles'
interface AbandonedCheckoutEmailProps {
userName?: string
}
export function AbandonedCheckoutEmail({ userName }: AbandonedCheckoutEmailProps) {
return (
<Html>
<Head />
<Preview>Did you run into an issue with your upgrade?</Preview>
<Body style={styles.body}>
<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.
</Text>
<Text style={styles.p}>
Did you run into an issue, or did you have a question? Here to help.
</Text>
<Text style={styles.p}>
Emir
<br />
Founder, Sim
</Text>
</div>
</Body>
</Html>
)
}
export default AbandonedCheckoutEmail
@@ -0,0 +1,7 @@
/** Pro plan features shown in billing upgrade emails */
export const proFeatures = [
{ label: '6,000 credits/month', desc: 'included' },
{ label: '+50 daily refresh', desc: 'credits per day' },
{ label: '150 runs/min', desc: 'sync executions' },
{ label: '50GB storage', desc: 'for files & assets' },
] as const
@@ -0,0 +1,99 @@
import { Link, Section, Text } from '@react-email/components'
import { baseStyles, colors } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getBrandConfig } from '@/ee/whitelabeling'
interface CreditPurchaseEmailProps {
userName?: string
amount: number
newBalance: number
purchaseDate?: Date
}
export function CreditPurchaseEmail({
userName,
amount,
newBalance,
purchaseDate = new Date(),
}: CreditPurchaseEmailProps) {
const brand = getBrandConfig()
const baseUrl = getBaseUrl()
const previewText = `${brand.name}: ${dollarsToCredits(amount).toLocaleString()} credits added to your account`
return (
<EmailLayout preview={previewText} showUnsubscribe={false}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{userName ? `Hi ${userName},` : 'Hi,'}
</Text>
<Text style={baseStyles.paragraph}>
Your credit purchase of <strong>{dollarsToCredits(amount).toLocaleString()} credits</strong>{' '}
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,
}}
>
{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,
}}
>
{dollarsToCredits(newBalance).toLocaleString()} credits
</Text>
</Section>
<Text style={baseStyles.paragraph}>
Credits are applied automatically to your workflow executions.
</Text>
<Link href={`${baseUrl}/workspace`} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>View Dashboard</Text>
</Link>
{/* Divider */}
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
Purchased on {purchaseDate.toLocaleDateString()}. View balance in Settings Subscription.
</Text>
</EmailLayout>
)
}
export default CreditPurchaseEmail
@@ -0,0 +1,102 @@
import { Link, Section, Text } from '@react-email/components'
import { baseStyles, colors, typography } from '@/components/emails/_styles'
import { proFeatures } from '@/components/emails/billing/constants'
import { EmailLayout } from '@/components/emails/components'
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
import { getBrandConfig } from '@/ee/whitelabeling'
interface CreditsExhaustedEmailProps {
userName?: string
limit: number
upgradeLink: string
}
export function CreditsExhaustedEmail({
userName,
limit,
upgradeLink,
}: CreditsExhaustedEmailProps) {
const brand = getBrandConfig()
return (
<EmailLayout
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.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.
</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>
<Link href={upgradeLink} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Upgrade to Pro</Text>
</Link>
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
One-time notification when free credits are exhausted.
</Text>
</EmailLayout>
)
}
export default CreditsExhaustedEmail
@@ -0,0 +1,54 @@
import { Link, Text } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getBrandConfig } from '@/ee/whitelabeling'
interface EnterpriseSubscriptionEmailProps {
userName?: string
loginLink?: string
}
export function EnterpriseSubscriptionEmail({
userName = 'Valued User',
loginLink,
}: EnterpriseSubscriptionEmailProps) {
const brand = getBrandConfig()
const baseUrl = getBaseUrl()
const effectiveLoginLink = loginLink || `${baseUrl}/login`
return (
<EmailLayout
preview={`Your Enterprise Plan is now active on ${brand.name}`}
showUnsubscribe={false}
>
<Text style={baseStyles.paragraph}>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.
</Text>
<Link href={effectiveLoginLink} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Open {brand.name}</Text>
</Link>
<Text style={baseStyles.paragraph}>
<strong>Next steps:</strong>
<br /> Invite teammates to your organization
<br /> Start building your workflows
</Text>
{/* Divider */}
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
Questions? Reply to this email or contact us at{' '}
<Link href={`mailto:${brand.supportEmail}`} style={baseStyles.link}>
{brand.supportEmail}
</Link>
</Text>
</EmailLayout>
)
}
export default EnterpriseSubscriptionEmail
@@ -0,0 +1,108 @@
import { Link, Section, Text } from '@react-email/components'
import { baseStyles, colors, typography } from '@/components/emails/_styles'
import { proFeatures } from '@/components/emails/billing/constants'
import { EmailLayout } from '@/components/emails/components'
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
import { getBrandConfig } from '@/ee/whitelabeling'
interface FreeTierUpgradeEmailProps {
userName?: string
percentUsed: number
currentUsage: number
limit: number
upgradeLink: string
}
export function FreeTierUpgradeEmail({
userName,
percentUsed,
currentUsage,
limit,
upgradeLink,
}: FreeTierUpgradeEmailProps) {
const brand = getBrandConfig()
const previewText = `${brand.name}: You've used ${percentUsed}% of your free credits`
return (
<EmailLayout preview={previewText} showUnsubscribe={true}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{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.
</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>
<Link href={upgradeLink} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Upgrade to Pro</Text>
</Link>
{/* Divider */}
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
One-time notification at 80% usage.
</Text>
</EmailLayout>
)
}
export default FreeTierUpgradeEmail
@@ -0,0 +1,9 @@
export { AbandonedCheckoutEmail } from './abandoned-checkout-email'
export { CreditPurchaseEmail } from './credit-purchase-email'
export { CreditsExhaustedEmail } from './credits-exhausted-email'
export { EnterpriseSubscriptionEmail } from './enterprise-subscription-email'
export { FreeTierUpgradeEmail } from './free-tier-upgrade-email'
export { LimitThresholdEmail } from './limit-threshold-email'
export { PaymentFailedEmail } from './payment-failed-email'
export { PlanWelcomeEmail } from './plan-welcome-email'
export { UsageThresholdEmail } from './usage-threshold-email'
@@ -0,0 +1,76 @@
import { Link, Section, Text } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
import { UPGRADE_REASON_COPY, type UpgradeReason } from '@/lib/billing/upgrade-reasons'
import { getBrandConfig } from '@/ee/whitelabeling'
interface LimitThresholdEmailProps {
/** `warning` = approaching the limit (~80%); `reached` = at/over the limit. */
kind: 'warning' | 'reached'
/** Limit category, drives the shared copy. */
reason: UpgradeReason
userName?: string
/** Pre-formatted current usage, e.g. "4.2 GB", "48,000 rows", "9 seats". */
usageLabel: string
/** Pre-formatted limit, e.g. "5 GB", "50,000 rows", "10 seats". */
limitLabel: string
percentUsed: number
upgradeLink: string
}
/**
* Single template for the per-category usage-limit emails (storage, tables,
* seats). Copy comes from {@link UPGRADE_REASON_COPY} so the email language
* matches the upgrade-page header the user lands on.
*/
export function LimitThresholdEmail({
kind,
reason,
userName,
usageLabel,
limitLabel,
percentUsed,
upgradeLink,
}: LimitThresholdEmailProps) {
const brand = getBrandConfig()
const copy = UPGRADE_REASON_COPY[reason]
const lead = kind === 'reached' ? copy.reachedLead : copy.warningLead
const previewText = `${brand.name}: ${lead}`
return (
<EmailLayout preview={previewText} showUnsubscribe={true}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{userName ? `Hi ${userName},` : 'Hi,'}
</Text>
<Text style={baseStyles.paragraph}>
{lead} Upgrade your plan for more {copy.noun}.
</Text>
<Section style={baseStyles.infoBox}>
<Text style={baseStyles.infoBoxTitle}>Usage</Text>
<Text style={baseStyles.infoBoxList}>
{usageLabel} of {limitLabel} used ({percentUsed}%)
</Text>
</Section>
{/* Divider */}
<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' }}>
{kind === 'reached'
? 'One-time notification at 100% usage.'
: 'One-time notification at 80% usage.'}
</Text>
</EmailLayout>
)
}
export default LimitThresholdEmail
@@ -0,0 +1,109 @@
import { Link, Section, Text } from '@react-email/components'
import { baseStyles, colors } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
import { getBrandConfig } from '@/ee/whitelabeling'
interface PaymentFailedEmailProps {
userName?: string
amountDue: number
lastFourDigits?: string
billingPortalUrl: string
failureReason?: string
sentDate?: Date
}
export function PaymentFailedEmail({
userName,
amountDue,
lastFourDigits,
billingPortalUrl,
failureReason,
sentDate = new Date(),
}: PaymentFailedEmailProps) {
const brand = getBrandConfig()
const previewText = `${brand.name}: Payment Failed - Action Required`
return (
<EmailLayout preview={previewText} showUnsubscribe={false}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{userName ? `Hi ${userName},` : 'Hi,'}
</Text>
<Text
style={{
...baseStyles.paragraph,
fontSize: '16px',
fontWeight: 600,
color: colors.textPrimary,
}}
>
We were unable to process your payment.
</Text>
<Text style={baseStyles.paragraph}>
Your {brand.name} account has been temporarily blocked to prevent service interruptions and
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' }}>
Amount due: ${amountDue.toFixed(2)}
</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>
{/* 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' }}>
Common issues: expired card, insufficient funds, or incorrect billing info. Need help?{' '}
<Link href={`mailto:${brand.supportEmail}`} style={baseStyles.link}>
{brand.supportEmail}
</Link>
</Text>
</EmailLayout>
)
}
export default PaymentFailedEmail
@@ -0,0 +1,52 @@
import { Link, Text } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getBrandConfig } from '@/ee/whitelabeling'
interface PlanWelcomeEmailProps {
planName: string
userName?: string
loginLink?: string
}
export function PlanWelcomeEmail({ planName, userName, loginLink }: PlanWelcomeEmailProps) {
const brand = getBrandConfig()
const baseUrl = getBaseUrl()
const cta = loginLink || `${baseUrl}/login`
const previewText = `${brand.name}: Your ${planName} plan is active`
return (
<EmailLayout preview={previewText} showUnsubscribe={true}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{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.
</Text>
<Link href={cta} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Open {brand.name}</Text>
</Link>
<Text style={baseStyles.paragraph}>
Want help getting started?{' '}
<Link href={`${baseUrl}/team`} style={baseStyles.link}>
Schedule a call
</Link>{' '}
with our team.
</Text>
{/* Divider */}
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
Manage your subscription in Settings Subscription.
</Text>
</EmailLayout>
)
}
export default PlanWelcomeEmail
@@ -0,0 +1,67 @@
import { Link, Section, Text } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
import { dollarsToCredits } from '@/lib/billing/credits/conversion'
import { getBrandConfig } from '@/ee/whitelabeling'
interface UsageThresholdEmailProps {
userName?: string
planName: string
percentUsed: number
currentUsage: number
limit: number
ctaLink: string
}
export function UsageThresholdEmail({
userName,
planName,
percentUsed,
currentUsage,
limit,
ctaLink,
}: UsageThresholdEmailProps) {
const brand = getBrandConfig()
const previewText = `${brand.name}: You're at ${percentUsed}% of your ${planName} monthly budget`
return (
<EmailLayout preview={previewText} showUnsubscribe={true}>
<Text style={{ ...baseStyles.paragraph, marginTop: 0 }}>
{userName ? `Hi ${userName},` : 'Hi,'}
</Text>
<Text style={baseStyles.paragraph}>
You're approaching your monthly budget on the {planName} plan.
</Text>
<Section style={baseStyles.infoBox}>
<Text style={baseStyles.infoBoxTitle}>Usage</Text>
<Text style={baseStyles.infoBoxList}>
{dollarsToCredits(currentUsage).toLocaleString()} of{' '}
{dollarsToCredits(limit).toLocaleString()} credits used ({percentUsed}%)
</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>
{/* Divider */}
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
One-time notification at 80% usage.
</Text>
</EmailLayout>
)
}
export default UsageThresholdEmail
@@ -0,0 +1,253 @@
import { Container, Img, Link, Section } from '@react-email/components'
import { baseStyles, colors, spacing, typography } from '@/components/emails/_styles'
import { isHosted } from '@/lib/core/config/env-flags'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getBrandConfig } from '@/ee/whitelabeling'
interface EmailFooterProps {
baseUrl?: string
messageId?: string
/**
* Whether to show unsubscribe link. Defaults to true.
* Set to false for transactional emails where unsubscribe doesn't apply.
*/
showUnsubscribe?: boolean
}
/**
* Email footer component styled to match Stripe's email design.
* Sits in the gray area below the main white card.
*
* For non-transactional emails, the unsubscribe link uses placeholders
* {{UNSUBSCRIBE_TOKEN}} and {{UNSUBSCRIBE_EMAIL}} which are replaced
* by the mailer when sending.
*/
export function EmailFooter({
baseUrl = getBaseUrl(),
messageId,
showUnsubscribe = true,
}: EmailFooterProps) {
const brand = getBrandConfig()
const isWhitelabeled = brand.isWhitelabeled
const footerLinkStyle = {
color: colors.textMuted,
textDecoration: 'underline',
fontWeight: 'normal' as const,
fontFamily: typography.fontFamily,
}
return (
<Section
style={{
backgroundColor: colors.footerBg,
width: '100%',
}}
>
<Container style={{ maxWidth: `${spacing.containerWidth}px`, margin: '0 auto' }}>
<table
cellPadding={0}
cellSpacing={0}
border={0}
width='100%'
style={{ minWidth: `${spacing.containerWidth}px` }}
>
<tbody>
<tr>
<td style={baseStyles.spacer} height={32}>
&nbsp;
</td>
</tr>
{/* Social links row — hidden for whitelabeled instances */}
{!isWhitelabeled && (
<>
<tr>
<td style={baseStyles.gutter} width={spacing.gutter}>
&nbsp;
</td>
<td>
<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'
/>
</Link>
</td>
<td align='left' style={{ padding: '0 8px' }}>
<Link
href='https://www.linkedin.com/company/simdotai'
rel='noopener noreferrer'
>
<Img
src={`${baseUrl}/static/linkedin-icon.png`}
width='20'
height='20'
alt='LinkedIn'
/>
</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'
/>
</Link>
</td>
</tr>
</tbody>
</table>
</td>
<td style={baseStyles.gutter} width={spacing.gutter}>
&nbsp;
</td>
</tr>
<tr>
<td style={baseStyles.spacer} height={16}>
&nbsp;
</td>
</tr>
</>
)}
{/* Address row — hidden for whitelabeled instances */}
{!isWhitelabeled && (
<>
<tr>
<td style={baseStyles.gutter} width={spacing.gutter}>
&nbsp;
</td>
<td style={baseStyles.footerText}>
{brand.name}
{isHosted && <>, 80 Langton St, San Francisco, CA 94103, USA</>}
</td>
<td style={baseStyles.gutter} width={spacing.gutter}>
&nbsp;
</td>
</tr>
<tr>
<td style={baseStyles.spacer} height={8}>
&nbsp;
</td>
</tr>
</>
)}
{/* Contact row */}
<tr>
<td style={baseStyles.gutter} width={spacing.gutter}>
&nbsp;
</td>
<td style={baseStyles.footerText}>
Questions?{' '}
<a href={`mailto:${brand.supportEmail}`} style={footerLinkStyle}>
{brand.supportEmail}
</a>
</td>
<td style={baseStyles.gutter} width={spacing.gutter}>
&nbsp;
</td>
</tr>
<tr>
<td style={baseStyles.spacer} height={8}>
&nbsp;
</td>
</tr>
{/* Message ID row (optional) */}
{messageId && (
<>
<tr>
<td style={baseStyles.gutter} width={spacing.gutter}>
&nbsp;
</td>
<td style={baseStyles.footerText}>
Need to refer to this message? Use this ID: {messageId}
</td>
<td style={baseStyles.gutter} width={spacing.gutter}>
&nbsp;
</td>
</tr>
<tr>
<td style={baseStyles.spacer} height={8}>
&nbsp;
</td>
</tr>
</>
)}
{/* 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'>
Privacy Policy
</a>{' '}
{' '}
<a href={`${baseUrl}/terms`} style={footerLinkStyle} rel='noopener noreferrer'>
Terms of Service
</a>
{showUnsubscribe && (
<>
{' '}
{' '}
<a
href={`${baseUrl}/unsubscribe?token={{UNSUBSCRIBE_TOKEN}}&email={{UNSUBSCRIBE_EMAIL}}`}
style={footerLinkStyle}
rel='noopener noreferrer'
>
Unsubscribe
</a>
</>
)}
</td>
<td style={baseStyles.gutter} width={spacing.gutter}>
&nbsp;
</td>
</tr>
{/* Copyright row */}
<tr>
<td style={baseStyles.spacer} height={16}>
&nbsp;
</td>
</tr>
<tr>
<td style={baseStyles.gutter} width={spacing.gutter}>
&nbsp;
</td>
<td style={baseStyles.footerText}>
© {new Date().getFullYear()} {brand.name}, All Rights Reserved
</td>
<td style={baseStyles.gutter} width={spacing.gutter}>
&nbsp;
</td>
</tr>
<tr>
<td style={baseStyles.spacer} height={32}>
&nbsp;
</td>
</tr>
</tbody>
</table>
</Container>
</Section>
)
}
export default EmailFooter
@@ -0,0 +1,75 @@
import { Body, Container, Font, Head, Html, Img, Preview, Section } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { EmailFooter } from '@/components/emails/components/email-footer'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getBrandConfig } from '@/ee/whitelabeling'
interface EmailLayoutProps {
/** Preview text shown in email client list view */
preview: string
/** Email content to render inside the layout */
children: React.ReactNode
/** Optional: hide footer for internal emails */
hideFooter?: boolean
/**
* Whether to show unsubscribe link in footer.
* Set to false for transactional emails where unsubscribe doesn't apply.
*/
showUnsubscribe: boolean
}
/**
* Shared email layout wrapper providing consistent structure.
* Includes Html, Head, Body, Container with logo header, and Footer.
*/
export function EmailLayout({
preview,
children,
hideFooter = false,
showUnsubscribe,
}: EmailLayoutProps) {
const brand = getBrandConfig()
const baseUrl = getBaseUrl()
const hasCustomLogo = Boolean(brand.logoUrl)
return (
<Html>
<Head>
<Font
fontFamily='Season Sans'
fallbackFontFamily={['Helvetica', 'sans-serif']}
webFont={{
url: `${baseUrl}/brand/fonts/SeasonSansUprightsVF.woff2`,
format: 'woff2',
}}
fontWeight='300 800'
fontStyle='normal'
/>
</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`}
height='34'
{...(hasCustomLogo ? {} : { width: '70' })}
alt={brand.name}
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>
)
}
export default EmailLayout
@@ -0,0 +1,2 @@
export { EmailFooter } from './email-footer'
export { EmailLayout } from './email-layout'
+15
View File
@@ -0,0 +1,15 @@
// Styles
export * from './_styles'
// Auth emails
export * from './auth'
// Billing emails
export * from './billing'
// Shared components
export * from './components'
// Invitation emails
export * from './invitations'
// Render functions and subjects
export * from './render'
export * from './subjects'
// Support emails
export * from './support'
@@ -0,0 +1,108 @@
import { Link, Text } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
import { getBrandConfig } from '@/ee/whitelabeling'
interface WorkspaceInvitation {
workspaceId: string
workspaceName: string
permission: 'admin' | 'write' | 'read'
}
interface BatchInvitationEmailProps {
inviterName: string
organizationName: string
organizationRole: 'admin' | 'member'
workspaceInvitations: WorkspaceInvitation[]
acceptUrl: string
}
const getPermissionLabel = (permission: string) => {
switch (permission) {
case 'admin':
return 'Admin (full access)'
case 'write':
return 'Editor (can edit workflows)'
case 'read':
return 'Viewer (read-only access)'
default:
return permission
}
}
const getRoleLabel = (role: string) => {
switch (role) {
case 'admin':
return 'Admin'
case 'member':
return 'Member'
default:
return role
}
}
const EMPTY_INVITATIONS: WorkspaceInvitation[] = []
export function BatchInvitationEmail({
inviterName = 'Someone',
organizationName = 'the team',
organizationRole = 'member',
workspaceInvitations = EMPTY_INVITATIONS,
acceptUrl,
}: BatchInvitationEmailProps) {
const brand = getBrandConfig()
const hasWorkspaces = workspaceInvitations.length > 0
return (
<EmailLayout
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.paragraph}>
<strong>{inviterName}</strong> has invited you to join <strong>{organizationName}</strong>{' '}
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>
{/* Workspace Invitations */}
{hasWorkspaces && (
<>
<Text style={baseStyles.paragraph}>
<strong>
Workspace Access ({workspaceInvitations.length} workspace
{workspaceInvitations.length !== 1 ? 's' : ''}):
</strong>
</Text>
{workspaceInvitations.map((ws) => (
<Text key={ws.workspaceId} style={{ ...baseStyles.paragraph, marginLeft: '20px' }}>
<strong>{ws.workspaceName}</strong> - {getPermissionLabel(ws.permission)}
</Text>
))}
</>
)}
<Link href={acceptUrl} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Accept Invitation</Text>
</Link>
{/* Divider */}
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
Invitation expires in 7 days. If unexpected, you can ignore this email.
</Text>
</EmailLayout>
)
}
export default BatchInvitationEmail
@@ -0,0 +1,4 @@
export { BatchInvitationEmail } from './batch-invitation-email'
export { InvitationEmail } from './invitation-email'
export { WorkspaceAddedEmail } from './workspace-added-email'
export { WorkspaceInvitationEmail } from './workspace-invitation-email'
@@ -0,0 +1,63 @@
import { Link, Text } from '@react-email/components'
import { createLogger } from '@sim/logger'
import { baseStyles } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { getBrandConfig } from '@/ee/whitelabeling'
interface InvitationEmailProps {
inviterName?: string
organizationName?: string
inviteLink?: string
}
const logger = createLogger('InvitationEmail')
export function InvitationEmail({
inviterName = 'A team member',
organizationName = 'an organization',
inviteLink = '',
}: InvitationEmailProps) {
const brand = getBrandConfig()
const baseUrl = getBaseUrl()
let enhancedLink = inviteLink
if (inviteLink && !inviteLink.includes('token=')) {
try {
const url = new URL(inviteLink)
const invitationId = url.pathname.split('/').pop()
if (invitationId) {
enhancedLink = `${baseUrl}/invite/${invitationId}?token=${invitationId}`
}
} catch (e) {
logger.error('Error parsing invite link:', e)
}
}
return (
<EmailLayout
preview={`You've been invited to join ${organizationName} on ${brand.name}`}
showUnsubscribe={false}
>
<Text style={baseStyles.paragraph}>Hello,</Text>
<Text style={baseStyles.paragraph}>
<strong>{inviterName}</strong> invited you to join <strong>{organizationName}</strong> on{' '}
{brand.name}.
</Text>
<Link href={enhancedLink} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Accept Invitation</Text>
</Link>
{/* Divider */}
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
Invitation expires in 48 hours. If unexpected, you can ignore this email.
</Text>
</EmailLayout>
)
}
export default InvitationEmail
@@ -0,0 +1,44 @@
import { Link, Text } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
import { getBrandConfig } from '@/ee/whitelabeling'
interface WorkspaceAddedEmailProps {
/** Name of the workspace the recipient was added to. */
workspaceName?: string
/** Name of the person who added the recipient. */
inviterName?: string
/** Direct link to the workspace (no acceptance required). */
workspaceLink?: string
}
export function WorkspaceAddedEmail({
workspaceName = 'Workspace',
inviterName = 'Someone',
workspaceLink = '',
}: WorkspaceAddedEmailProps) {
const brand = getBrandConfig()
const preview = `You've been added to the "${workspaceName}" workspace on ${brand.name}`
return (
<EmailLayout preview={preview} showUnsubscribe={false}>
<Text style={baseStyles.paragraph}>Hello,</Text>
<Text style={baseStyles.paragraph}>
<strong>{inviterName}</strong> added you to the <strong>{workspaceName}</strong> workspace
on {brand.name}.
</Text>
<Link href={workspaceLink} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Open workspace</Text>
</Link>
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
If this was unexpected, contact a workspace admin.
</Text>
</EmailLayout>
)
}
export default WorkspaceAddedEmail
@@ -0,0 +1,56 @@
import { Link, Text } from '@react-email/components'
import { baseStyles } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
import { getBrandConfig } from '@/ee/whitelabeling'
interface WorkspaceInvitationEmailProps {
/** Workspaces this invitation grants access to (one entry per workspace). */
workspaceNames?: string[]
inviterName?: string
invitationLink?: string
}
export function WorkspaceInvitationEmail({
workspaceNames = ['Workspace'],
inviterName = 'Someone',
invitationLink = '',
}: WorkspaceInvitationEmailProps) {
const brand = getBrandConfig()
const isMultiple = workspaceNames.length > 1
const preview = isMultiple
? `You've been invited to join ${workspaceNames.length} workspaces on ${brand.name}!`
: `You've been invited to join the "${workspaceNames[0]}" workspace on ${brand.name}!`
return (
<EmailLayout preview={preview} showUnsubscribe={false}>
<Text style={baseStyles.paragraph}>Hello,</Text>
<Text style={baseStyles.paragraph}>
<strong>{inviterName}</strong> invited you to join the{' '}
{workspaceNames.map((name, index) => (
<span key={`${name}-${index}`}>
{index > 0 &&
(index === workspaceNames.length - 1
? workspaceNames.length > 2
? ', and '
: ' and '
: ', ')}
<strong>{name}</strong>
</span>
))}{' '}
{isMultiple ? 'workspaces' : 'workspace'} on {brand.name}.
</Text>
<Link href={invitationLink} style={{ textDecoration: 'none' }}>
<Text style={baseStyles.button}>Accept Invitation</Text>
</Link>
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
Invitation expires in 7 days. If unexpected, you can ignore this email.
</Text>
</EmailLayout>
)
}
export default WorkspaceInvitationEmail
+262
View File
@@ -0,0 +1,262 @@
import { render } from '@react-email/render'
import {
ExistingAccountEmail,
OnboardingFollowupEmail,
OTPVerificationEmail,
ResetPasswordEmail,
WelcomeEmail,
} from '@/components/emails/auth'
import {
AbandonedCheckoutEmail,
CreditPurchaseEmail,
CreditsExhaustedEmail,
EnterpriseSubscriptionEmail,
FreeTierUpgradeEmail,
LimitThresholdEmail,
PaymentFailedEmail,
PlanWelcomeEmail,
UsageThresholdEmail,
} from '@/components/emails/billing'
import {
BatchInvitationEmail,
InvitationEmail,
WorkspaceAddedEmail,
WorkspaceInvitationEmail,
} from '@/components/emails/invitations'
import { HelpConfirmationEmail } from '@/components/emails/support'
import type { UpgradeReason } from '@/lib/billing/upgrade-reasons'
import { getBaseUrl } from '@/lib/core/utils/urls'
export { getEmailSubject, getLimitEmailSubject } from './subjects'
interface WorkspaceInvitation {
workspaceId: string
workspaceName: string
permission: 'admin' | 'write' | 'read'
}
export async function renderOTPEmail(
otp: string,
email: string,
type:
| 'sign-in'
| 'email-verification'
| 'change-email'
| 'forget-password' = 'email-verification',
chatTitle?: string
): Promise<string> {
return await render(OTPVerificationEmail({ otp, email, type, chatTitle }))
}
export async function renderExistingAccountEmail(username: string): Promise<string> {
return await render(ExistingAccountEmail({ username }))
}
export async function renderPasswordResetEmail(
username: string,
resetLink: string
): Promise<string> {
return await render(ResetPasswordEmail({ username, resetLink }))
}
export async function renderInvitationEmail(
inviterName: string,
organizationName: string,
invitationUrl: string
): Promise<string> {
return await render(
InvitationEmail({
inviterName,
organizationName,
inviteLink: invitationUrl,
})
)
}
export async function renderBatchInvitationEmail(
inviterName: string,
organizationName: string,
organizationRole: 'admin' | 'member',
workspaceInvitations: WorkspaceInvitation[],
acceptUrl: string
): Promise<string> {
return await render(
BatchInvitationEmail({
inviterName,
organizationName,
organizationRole,
workspaceInvitations,
acceptUrl,
})
)
}
export async function renderHelpConfirmationEmail(
type: 'bug' | 'feedback' | 'feature_request' | 'other',
attachmentCount = 0
): Promise<string> {
return await render(
HelpConfirmationEmail({
type,
attachmentCount,
submittedDate: new Date(),
})
)
}
export async function renderEnterpriseSubscriptionEmail(userName: string): Promise<string> {
const baseUrl = getBaseUrl()
const loginLink = `${baseUrl}/login`
return await render(
EnterpriseSubscriptionEmail({
userName,
loginLink,
})
)
}
export async function renderUsageThresholdEmail(params: {
userName?: string
planName: string
percentUsed: number
currentUsage: number
limit: number
ctaLink: string
}): Promise<string> {
return await render(
UsageThresholdEmail({
userName: params.userName,
planName: params.planName,
percentUsed: params.percentUsed,
currentUsage: params.currentUsage,
limit: params.limit,
ctaLink: params.ctaLink,
})
)
}
export async function renderFreeTierUpgradeEmail(params: {
userName?: string
percentUsed: number
currentUsage: number
limit: number
upgradeLink: string
}): Promise<string> {
return await render(
FreeTierUpgradeEmail({
userName: params.userName,
percentUsed: params.percentUsed,
currentUsage: params.currentUsage,
limit: params.limit,
upgradeLink: params.upgradeLink,
})
)
}
export async function renderLimitThresholdEmail(params: {
kind: 'warning' | 'reached'
reason: UpgradeReason
userName?: string
usageLabel: string
limitLabel: string
percentUsed: number
upgradeLink: string
}): Promise<string> {
return await render(LimitThresholdEmail(params))
}
export async function renderPlanWelcomeEmail(params: {
planName: string
userName?: string
loginLink?: string
}): Promise<string> {
return await render(
PlanWelcomeEmail({
planName: params.planName,
userName: params.userName,
loginLink: params.loginLink,
})
)
}
export async function renderWelcomeEmail(userName?: string): Promise<string> {
return await render(WelcomeEmail({ userName }))
}
export async function renderOnboardingFollowupEmail(userName?: string): Promise<string> {
return await render(OnboardingFollowupEmail({ userName }))
}
export async function renderAbandonedCheckoutEmail(userName?: string): Promise<string> {
return await render(AbandonedCheckoutEmail({ userName }))
}
export async function renderCreditsExhaustedEmail(params: {
userName?: string
limit: number
upgradeLink: string
}): Promise<string> {
return await render(CreditsExhaustedEmail(params))
}
export async function renderCreditPurchaseEmail(params: {
userName?: string
amount: number
newBalance: number
}): Promise<string> {
return await render(
CreditPurchaseEmail({
userName: params.userName,
amount: params.amount,
newBalance: params.newBalance,
purchaseDate: new Date(),
})
)
}
export async function renderWorkspaceInvitationEmail(
inviterName: string,
workspaceNames: string[],
invitationLink: string
): Promise<string> {
return await render(
WorkspaceInvitationEmail({
inviterName,
workspaceNames,
invitationLink,
})
)
}
export async function renderWorkspaceAddedEmail(
inviterName: string,
workspaceName: string,
workspaceLink: string
): Promise<string> {
return await render(
WorkspaceAddedEmail({
inviterName,
workspaceName,
workspaceLink,
})
)
}
export async function renderPaymentFailedEmail(params: {
userName?: string
amountDue: number
lastFourDigits?: string
billingPortalUrl: string
failureReason?: string
}): Promise<string> {
return await render(
PaymentFailedEmail({
userName: params.userName,
amountDue: params.amountDue,
lastFourDigits: params.lastFourDigits,
billingPortalUrl: params.billingPortalUrl,
failureReason: params.failureReason,
})
)
}
+91
View File
@@ -0,0 +1,91 @@
import { UPGRADE_REASON_COPY, type UpgradeReason } from '@/lib/billing/upgrade-reasons'
import { getBrandConfig } from '@/ee/whitelabeling'
/** Email subject type for all supported email templates */
export type EmailSubjectType =
| 'sign-in'
| 'email-verification'
| 'change-email'
| 'forget-password'
| 'reset-password'
| 'existing-account'
| 'invitation'
| 'batch-invitation'
| 'workspace-added'
| 'help-confirmation'
| 'enterprise-subscription'
| 'usage-threshold'
| 'free-tier-upgrade'
| 'plan-welcome-pro'
| 'plan-welcome-team'
| 'credit-purchase'
| 'abandoned-checkout'
| 'free-tier-exhausted'
| 'onboarding-followup'
| 'welcome'
/**
* Returns the email subject line for a given email type.
* @param type - The type of email being sent
* @returns The subject line for the email
*/
export function getEmailSubject(type: EmailSubjectType): string {
const brandName = getBrandConfig().name
switch (type) {
case 'sign-in':
return `Sign in to ${brandName}`
case 'email-verification':
return `Verify your email for ${brandName}`
case 'change-email':
return `Verify your new email for ${brandName}`
case 'forget-password':
return `Reset your ${brandName} password`
case 'reset-password':
return `Reset your ${brandName} password`
case 'existing-account':
return `Sign-up attempt with your ${brandName} email`
case 'invitation':
return `You've been invited to join a team on ${brandName}`
case 'batch-invitation':
return `You've been invited to join a team and workspaces on ${brandName}`
case 'workspace-added':
return `You've been added to a workspace on ${brandName}`
case 'help-confirmation':
return 'Your request has been received'
case 'enterprise-subscription':
return `Your Enterprise Plan is now active on ${brandName}`
case 'usage-threshold':
return `You're nearing your monthly budget on ${brandName}`
case 'free-tier-upgrade':
return `You're at 80% of your free credits on ${brandName}`
case 'plan-welcome-pro':
return `Your Pro plan is now active on ${brandName}`
case 'plan-welcome-team':
return `Your Team plan is now active on ${brandName}`
case 'credit-purchase':
return `Credits added to your ${brandName} account`
case 'abandoned-checkout':
return `Quick question`
case 'free-tier-exhausted':
return `You've run out of free credits on ${brandName}`
case 'onboarding-followup':
return `Quick question about ${brandName}`
case 'welcome':
return `Welcome to ${brandName}`
default:
return brandName
}
}
/**
* Subject line for a per-category usage-limit email. Reuses the shared
* {@link UPGRADE_REASON_COPY} so the subject matches the email body and the
* upgrade-page header the user lands on.
*/
export function getLimitEmailSubject(reason: UpgradeReason, kind: 'warning' | 'reached'): string {
const brandName = getBrandConfig().name
const copy = UPGRADE_REASON_COPY[reason]
const subject = kind === 'reached' ? copy.reachedSubject : copy.warningSubject
return `${subject} on ${brandName}`
}
@@ -0,0 +1,61 @@
import { Text } from '@react-email/components'
import { format } from 'date-fns'
import { baseStyles } from '@/components/emails/_styles'
import { EmailLayout } from '@/components/emails/components'
interface HelpConfirmationEmailProps {
type?: 'bug' | 'feedback' | 'feature_request' | 'other'
attachmentCount?: number
submittedDate?: Date
}
const getTypeLabel = (type: string) => {
switch (type) {
case 'bug':
return 'Bug Report'
case 'feedback':
return 'Feedback'
case 'feature_request':
return 'Feature Request'
case 'other':
return 'General Inquiry'
default:
return 'Request'
}
}
export function HelpConfirmationEmail({
type = 'other',
attachmentCount = 0,
submittedDate = new Date(),
}: HelpConfirmationEmailProps) {
const typeLabel = getTypeLabel(type)
return (
<EmailLayout
preview={`Your ${typeLabel.toLowerCase()} has been received`}
showUnsubscribe={false}
>
<Text style={baseStyles.paragraph}>Hello,</Text>
<Text style={baseStyles.paragraph}>
We've received your <strong>{typeLabel.toLowerCase()}</strong> and will get back to you
shortly.
</Text>
{attachmentCount > 0 && (
<Text style={baseStyles.paragraph}>
{attachmentCount} image{attachmentCount > 1 ? 's' : ''} attached.
</Text>
)}
{/* Divider */}
<div style={baseStyles.divider} />
<Text style={{ ...baseStyles.footerText, textAlign: 'left' }}>
Submitted on {format(submittedDate, 'MMMM do, yyyy')}.
</Text>
</EmailLayout>
)
}
export default HelpConfirmationEmail
@@ -0,0 +1 @@
export { HelpConfirmationEmail } from './help-confirmation-email'